From c624939ff1a625cbe1ac0408c4e11ebefea1fd44 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 13 Aug 2026 19:27:29 +0800 Subject: [PATCH 1/4] refactor: drop per-block payload size and tx-count packing caps These leftover zkEVM packing knobs are not Morph consensus parameters. Stop reading the unused genesis fields and remove the CLI flags so the builder is bounded only by header gasLimit and the time budget. --- README.md | 2 - crates/chainspec/src/genesis.rs | 41 +++--- crates/chainspec/src/spec.rs | 17 +-- crates/node/src/args.rs | 94 +++---------- crates/node/src/components/payload.rs | 12 -- crates/node/src/node.rs | 27 +--- crates/payload/builder/src/builder.rs | 124 +++-------------- crates/payload/builder/src/config.rs | 191 +++----------------------- 8 files changed, 82 insertions(+), 426 deletions(-) diff --git a/README.md b/README.md index a2239452..2c82e95a 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,6 @@ openssl rand -hex 32 > jwt.hex | Flag | Default | Description | |------|---------|-------------| -| `--morph.max-tx-payload-bytes` | 122880 (120KB) | Maximum transaction payload bytes per block | -| `--morph.max-tx-per-block` | None (unlimited) | Maximum number of transactions per block | | `--rpc.eth-proof-window` | 0 (disabled) | Max historical blocks for `eth_getProof` (up to 1209600) | ### Running Tests diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index b296b1b5..bc68f3d1 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -80,15 +80,16 @@ impl TryFrom<&OtherFields> for MorphHardforkInfo { } /// The configuration for the Morph chain. +/// +/// Unused genesis keys such as `maxTxPayloadBytesPerBlock` and `maxTxPerBlock` +/// are ignored. They are leftover zkEVM packing limits, not Morph consensus +/// parameters, and are not consumed by the payload builder. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphChainConfig { /// The address of the L2 transaction fee vault. #[serde(skip_serializing_if = "Option::is_none")] pub fee_vault_address: Option
, - /// The maximum tx payload size per block in bytes. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_tx_payload_bytes_per_block: Option, } impl MorphChainConfig { @@ -101,13 +102,6 @@ impl MorphChainConfig { pub const fn is_fee_vault_enabled(&self) -> bool { self.fee_vault_address.is_some() } - - /// Checks if the given block size (in bytes) is valid for this chain. - pub fn is_valid_block_size(&self, size: usize) -> bool { - self.max_tx_payload_bytes_per_block - .map(|max| size <= max) - .unwrap_or(true) - } } impl TryFrom<&OtherFields> for MorphChainConfig { @@ -174,10 +168,7 @@ mod tests { config.fee_vault_address, Some(address!("530000000000000000000000000000000000000a")) ); - assert_eq!(config.max_tx_payload_bytes_per_block, Some(122880)); assert!(config.is_fee_vault_enabled()); - assert!(config.is_valid_block_size(100000)); - assert!(!config.is_valid_block_size(200000)); } #[test] @@ -185,8 +176,26 @@ mod tests { let config = MorphChainConfig::default(); assert!(!config.is_fee_vault_enabled()); assert_eq!(config.fee_vault_address, None); - assert_eq!(config.max_tx_payload_bytes_per_block, None); - // Without max size limit, any size is valid - assert!(config.is_valid_block_size(usize::MAX)); + } + + #[test] + fn test_ignores_unused_packing_fields() { + let config_str = r#" + { + "morph": { + "feeVaultAddress": "0x530000000000000000000000000000000000000a", + "maxTxPayloadBytesPerBlock": 122880, + "maxTxPerBlock": 100 + } + } + "#; + + let others: OtherFields = serde_json::from_str(config_str).unwrap(); + let config = MorphChainConfig::extract_from(&others).unwrap(); + + assert_eq!( + config.fee_vault_address, + Some(address!("530000000000000000000000000000000000000a")) + ); } } diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 39e83e93..6477747c 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -301,16 +301,6 @@ impl MorphChainSpec { pub fn fee_vault_address(&self) -> Option
{ self.info.morph_chain_info.fee_vault_address } - - /// Returns the maximum tx payload size per block in bytes. - pub fn max_tx_payload_bytes_per_block(&self) -> Option { - self.info.morph_chain_info.max_tx_payload_bytes_per_block - } - - /// Checks if the given block size (in bytes) is valid for this chain. - pub fn is_valid_block_size(&self, size: usize) -> bool { - self.info.morph_chain_info.is_valid_block_size(size) - } } impl From for MorphChainSpec { @@ -716,9 +706,10 @@ mod tests { let chainspec = MorphChainSpec::from(genesis); assert!(chainspec.is_fee_vault_enabled()); - assert_eq!(chainspec.max_tx_payload_bytes_per_block(), Some(122880)); - assert!(chainspec.is_valid_block_size(100000)); - assert!(!chainspec.is_valid_block_size(200000)); + assert_eq!( + chainspec.fee_vault_address(), + Some(address!("530000000000000000000000000000000000000a")) + ); } #[test] diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs index 7e5e9233..619a4cd2 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -2,47 +2,14 @@ use clap::Args; -/// Default maximum transaction payload bytes per block (120KB). -/// -/// This matches Morph's go-ethereum configuration. -pub const MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES: u64 = 122_880; - /// Morph-specific CLI arguments. /// -/// These arguments extend the standard reth CLI with Morph-specific options -/// for block building and transaction limits. +/// Extends the standard reth CLI. Currently has no Morph-only flags: block packing is +/// bounded by header `gasLimit` and the payload builder time budget. /// /// Note: Block building deadline is configured via reth's built-in `--builder.deadline` flag. -#[derive(Debug, Clone, Args)] -#[command(next_help_heading = "Morph")] -pub struct MorphArgs { - /// Maximum transaction payload bytes per block. - /// - /// Limits the total size of transactions included in a single block. - /// Default: 122880 bytes (120KB), matching Morph's go-ethereum configuration. - #[arg( - long = "morph.max-tx-payload-bytes", - value_name = "BYTES", - default_value_t = MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES - )] - pub max_tx_payload_bytes: u64, - - /// Maximum number of transactions per block. - /// - /// If not set, there is no limit on the number of transactions. - /// Morph Holesky testnet uses 1000 as the default limit. - #[arg(long = "morph.max-tx-per-block", value_name = "COUNT")] - pub max_tx_per_block: Option, -} - -impl Default for MorphArgs { - fn default() -> Self { - Self { - max_tx_payload_bytes: MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES, - max_tx_per_block: None, - } - } -} +#[derive(Debug, Clone, Args, Default)] +pub struct MorphArgs {} #[cfg(test)] mod tests { @@ -57,40 +24,7 @@ mod tests { #[test] fn test_default_args() { - let args = CommandParser::::parse_from(["test"]).args; - assert_eq!( - args.max_tx_payload_bytes, - MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES - ); - assert_eq!(args.max_tx_per_block, None); - } - - #[test] - fn test_custom_args() { - let args = CommandParser::::parse_from([ - "test", - "--morph.max-tx-payload-bytes", - "100000", - "--morph.max-tx-per-block", - "500", - ]) - .args; - assert_eq!(args.max_tx_payload_bytes, 100000); - assert_eq!(args.max_tx_per_block, Some(500)); - } - - #[test] - fn test_all_args_combined() { - let args = CommandParser::::parse_from([ - "test", - "--morph.max-tx-payload-bytes", - "200000", - "--morph.max-tx-per-block", - "1000", - ]) - .args; - assert_eq!(args.max_tx_payload_bytes, 200000); - assert_eq!(args.max_tx_per_block, Some(1000)); + let _args = CommandParser::::parse_from(["test"]).args; } #[test] @@ -106,12 +40,18 @@ mod tests { } #[test] - fn test_default_trait_impl() { - let args = MorphArgs::default(); - assert_eq!( - args.max_tx_payload_bytes, - MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + fn unused_packing_flags_are_not_supported() { + assert!( + CommandParser::::try_parse_from([ + "test", + "--morph.max-tx-payload-bytes", + "1" + ]) + .is_err() + ); + assert!( + CommandParser::::try_parse_from(["test", "--morph.max-tx-per-block", "1"]) + .is_err() ); - assert!(args.max_tx_per_block.is_none()); } } diff --git a/crates/node/src/components/payload.rs b/crates/node/src/components/payload.rs index 2035c676..fadff017 100644 --- a/crates/node/src/components/payload.rs +++ b/crates/node/src/components/payload.rs @@ -26,18 +26,6 @@ impl MorphPayloadBuilderBuilder { pub const fn new(config: MorphBuilderConfig) -> Self { Self { config } } - - /// Sets the maximum DA block size (transaction payload bytes per block). - pub fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { - self.config = self.config.with_max_da_block_size(max_da_block_size); - self - } - - /// Sets the maximum number of transactions per block. - pub fn with_max_tx_per_block(mut self, max_tx_per_block: u64) -> Self { - self.config = self.config.with_max_tx_per_block(max_tx_per_block); - self - } } impl diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 347cef93..a8aeb873 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -118,17 +118,7 @@ where type AddOns = MorphAddOns>; fn components_builder(&self) -> Self::ComponentsBuilder { - // Build payload config from args - let payload_config = - MorphBuilderConfig::default().with_max_da_block_size(self.args.max_tx_payload_bytes); - - let payload_config = if let Some(max_tx) = self.args.max_tx_per_block { - payload_config.with_max_tx_per_block(max_tx) - } else { - payload_config - }; - - Self::components(payload_config) + Self::components(MorphBuilderConfig::default()) } fn add_ons(&self) -> Self::AddOns { @@ -239,23 +229,12 @@ mod tests { #[test] fn morph_node_default() { - let node = MorphNode::default(); - assert_eq!( - node.args.max_tx_payload_bytes, - super::super::args::MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES - ); - assert!(node.args.max_tx_per_block.is_none()); + let _node = MorphNode::default(); } #[test] fn morph_node_new_with_args() { - let args = super::super::args::MorphArgs { - max_tx_payload_bytes: 200_000, - max_tx_per_block: Some(500), - }; - let node = MorphNode::new(args); - assert_eq!(node.args.max_tx_payload_bytes, 200_000); - assert_eq!(node.args.max_tx_per_block, Some(500)); + let _node = MorphNode::new(super::super::args::MorphArgs::default()); } #[test] diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index e2dc38b4..00571e17 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -5,7 +5,6 @@ use crate::{MorphBuilderConfig, MorphPayloadBuilderError, config::PayloadBuildin use alloy_consensus::{BlockHeader, Transaction, Typed2718}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Bytes, U256}; -use alloy_rlp::Encodable; use morph_chainspec::MorphChainSpec; use morph_chainspec::{L2_MESSAGE_QUEUE_ADDRESS, L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT}; use morph_evm::{MorphEvmConfig, MorphNextBlockEnvAttributes}; @@ -499,16 +498,11 @@ impl MorphPayloadBuilderCtx { return Ok(Some(())); } - // Check if the breaker triggers (time/gas/DA/tx count limits) - if breaker.should_break( - info.cumulative_gas_used, - info.cumulative_da_bytes_used, - info.transaction_count, - ) { + // Check if the breaker triggers (time or gas limits) + if breaker.should_break(info.cumulative_gas_used) { tracing::debug!( target: "payload_builder", cumulative_gas_used = info.cumulative_gas_used, - cumulative_da_bytes_used = info.cumulative_da_bytes_used, transaction_count = info.transaction_count, elapsed = ?breaker.elapsed(), "breaker triggered, stopping pool transaction execution" @@ -536,23 +530,15 @@ impl MorphPayloadBuilderCtx { continue; } - // Check if the transaction exceeds block limits (gas or DA size). - // Rare in practice; logged at debug to avoid pool-skip noise. - if info.is_tx_over_limits( - tx.gas_limit(), - tx.length() as u64, - block_gas_limit, - self.builder_config.max_da_block_size, - ) { + // Skip transactions that cannot fit in remaining block gas. + if info.is_tx_over_limits(tx.gas_limit(), block_gas_limit) { tracing::debug!( target: "payload_builder", signer = %tx.signer(), nonce = tx.nonce(), tx_gas_limit = tx.gas_limit(), - tx_size = tx.length(), block_gas_limit, - max_da_block_size = self.builder_config.max_da_block_size, - "pool transaction exceeds block limits; skipping" + "pool transaction exceeds remaining block gas; skipping" ); best_txs.mark_invalid(tx.signer(), tx.nonce()); continue; @@ -621,7 +607,6 @@ impl MorphPayloadBuilderCtx { // Update execution info info.cumulative_gas_used += gas_used; - info.cumulative_da_bytes_used += tx.length() as u64; info.transaction_count += 1; // Calculate fees: effective_tip * gas_used @@ -643,8 +628,6 @@ impl MorphPayloadBuilderCtx { struct ExecutionInfo { /// Cumulative gas used by all executed transactions. cumulative_gas_used: u64, - /// Cumulative DA bytes used (for L2 data availability). - cumulative_da_bytes_used: u64, /// Total fees collected from executed transactions. total_fees: U256, /// Next L1 message queue index. @@ -658,28 +641,14 @@ impl ExecutionInfo { const fn new(next_l1_message_index: u64) -> Self { Self { cumulative_gas_used: 0, - cumulative_da_bytes_used: 0, total_fees: U256::ZERO, next_l1_message_index, transaction_count: 0, } } - /// Returns true if the transaction would exceed the block limits. - fn is_tx_over_limits( - &self, - tx_gas_limit: u64, - tx_size: u64, - block_gas_limit: u64, - block_da_limit: Option, - ) -> bool { - // Check DA limit if configured - if block_da_limit.is_some_and(|da_limit| self.cumulative_da_bytes_used + tx_size > da_limit) - { - return true; - } - - // Check gas limit + /// Returns true if the transaction would exceed remaining block gas. + fn is_tx_over_limits(&self, tx_gas_limit: u64, block_gas_limit: u64) -> bool { self.cumulative_gas_used + tx_gas_limit > block_gas_limit } } @@ -785,7 +754,6 @@ where target: "payload_builder", elapsed = ?breaker.elapsed(), cumulative_gas_used = info.cumulative_gas_used, - cumulative_da_bytes_used = info.cumulative_da_bytes_used, tx_count = executed_txs.len(), "breaker stopped pool execution, finalizing payload" ); @@ -932,7 +900,6 @@ mod tests { fn test_execution_info_default() { let info = ExecutionInfo::default(); assert_eq!(info.cumulative_gas_used, 0); - assert_eq!(info.cumulative_da_bytes_used, 0); assert_eq!(info.total_fees, U256::ZERO); assert_eq!(info.next_l1_message_index, 0); assert_eq!(info.transaction_count, 0); @@ -943,7 +910,6 @@ mod tests { let info = ExecutionInfo::new(42); assert_eq!(info.next_l1_message_index, 42); assert_eq!(info.cumulative_gas_used, 0); - assert_eq!(info.cumulative_da_bytes_used, 0); assert_eq!(info.total_fees, U256::ZERO); assert_eq!(info.transaction_count, 0); } @@ -965,13 +931,13 @@ mod tests { // ========================================================================= #[test] - fn test_is_tx_over_limits_within_gas_no_da() { + fn test_is_tx_over_limits_within_gas() { let info = ExecutionInfo { cumulative_gas_used: 100_000, ..Default::default() }; // tx_gas + cumulative = 100_000 + 21_000 = 121_000, block limit = 30_000_000 - assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + assert!(!info.is_tx_over_limits(21_000, 30_000_000)); } #[test] @@ -981,7 +947,7 @@ mod tests { ..Default::default() }; // tx_gas + cumulative = 29_990_000 + 21_000 = 30_011_000 > 30_000_000 - assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + assert!(info.is_tx_over_limits(21_000, 30_000_000)); } #[test] @@ -992,7 +958,7 @@ mod tests { }; // tx_gas + cumulative = 29_979_000 + 21_000 = 30_000_000 == block limit // Uses > comparison, so exactly at limit is NOT over - assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, None)); + assert!(!info.is_tx_over_limits(21_000, 30_000_000)); } #[test] @@ -1002,77 +968,21 @@ mod tests { ..Default::default() }; // tx_gas + cumulative = 29_979_001 + 21_000 = 30_000_001 > 30_000_000 - assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, None)); - } - - #[test] - fn test_is_tx_over_limits_exceeds_da_limit() { - let info = ExecutionInfo { - cumulative_da_bytes_used: 120_000, - ..Default::default() - }; - // da_used + tx_size = 120_000 + 10_000 = 130_000 < 131_072, NOT over - assert!(!info.is_tx_over_limits(21_000, 10_000, 30_000_000, Some(128 * 1024))); - - // da_used + tx_size = 120_000 + 12_000 = 132_000 > 131_072 - assert!(info.is_tx_over_limits(21_000, 12_000, 30_000_000, Some(128 * 1024))); - } - - #[test] - fn test_is_tx_over_limits_da_limit_none_ignores_da() { - let info = ExecutionInfo { - cumulative_da_bytes_used: u64::MAX, - ..Default::default() - }; - // Even with max DA usage, no DA limit means it's not over - assert!(!info.is_tx_over_limits(21_000, 1_000, 30_000_000, None)); - } - - #[test] - fn test_is_tx_over_limits_da_limit_exactly_at_boundary() { - let info = ExecutionInfo { - cumulative_da_bytes_used: 100, - ..Default::default() - }; - // da_used + tx_size = 100 + 100 = 200 == da_limit, NOT over (uses > not >=) - assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000, Some(200))); - - // da_used + tx_size = 100 + 101 = 201 > 200 - assert!(info.is_tx_over_limits(21_000, 101, 30_000_000, Some(200))); - } - - #[test] - fn test_is_tx_over_limits_gas_ok_but_da_exceeded() { - let info = ExecutionInfo { - cumulative_gas_used: 100_000, - cumulative_da_bytes_used: 500, - ..Default::default() - }; - assert!(info.is_tx_over_limits(21_000, 600, 30_000_000, Some(1000))); - } - - #[test] - fn test_is_tx_over_limits_da_ok_but_gas_exceeded() { - let info = ExecutionInfo { - cumulative_gas_used: 29_990_000, - cumulative_da_bytes_used: 100, - ..Default::default() - }; - assert!(info.is_tx_over_limits(21_000, 100, 30_000_000, Some(1_000_000))); + assert!(info.is_tx_over_limits(21_000, 30_000_000)); } #[test] fn test_is_tx_over_limits_zero_gas_tx() { let info = ExecutionInfo::default(); - assert!(!info.is_tx_over_limits(0, 0, 30_000_000, None)); + assert!(!info.is_tx_over_limits(0, 30_000_000)); } #[test] fn test_is_tx_over_limits_zero_block_gas_limit() { let info = ExecutionInfo::default(); - assert!(info.is_tx_over_limits(1, 0, 0, None)); + assert!(info.is_tx_over_limits(1, 0)); // 0 > 0 is false - assert!(!info.is_tx_over_limits(0, 0, 0, None)); + assert!(!info.is_tx_over_limits(0, 0)); } // ========================================================================= @@ -1100,9 +1010,7 @@ mod tests { #[test] fn test_morph_payload_builder_set_config() { let builder = MorphPayloadBuilder::<(), ()>::new((), test_evm_config(), ()); - let config = MorphBuilderConfig::default() - .with_gas_limit(5_000_000) - .with_max_tx_per_block(500); + let config = MorphBuilderConfig::default().with_gas_limit(5_000_000); let builder = builder.set_config(config.clone()); assert_eq!(builder.config, config); } diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 7a618b5b..3e36a749 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -3,11 +3,6 @@ use core::time::Duration; use reth_chainspec::MIN_TRANSACTION_GAS; use reth_primitives_traits::FastInstant as Instant; -use std::fmt::Debug; - -/// Minimal data bytes size per transaction. -/// This is a conservative estimate for the minimum encoded transaction size. -pub(crate) const MIN_TRANSACTION_DATA_SIZE: u64 = 115; /// Settings for the Morph payload builder. #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,23 +25,6 @@ pub struct MorphBuilderConfig { /// once this duration has elapsed since the start of building. /// This ensures timely block production even with large mempools. pub time_limit: Duration, - - /// Maximum total data availability size for a block. - /// - /// L2 transactions need to be published to L1 for data availability. - /// This limit controls the maximum size of transaction data in a single block. - /// If `None`, no DA limit is enforced. - /// - /// This corresponds to the `--morph.max-tx-payload-bytes` CLI flag. - pub max_da_block_size: Option, - - /// Maximum number of transactions per block. - /// - /// If set, the builder will stop adding transactions once this limit is reached. - /// If `None`, no transaction count limit is enforced. - /// - /// This corresponds to the `--morph.max-tx-per-block` CLI flag. - pub max_tx_per_block: Option, } impl Default for MorphBuilderConfig { @@ -55,27 +33,16 @@ impl Default for MorphBuilderConfig { gas_limit: None, // Default to 1 second - leaves time for consensus time_limit: Duration::from_secs(1), - // No DA limit by default - max_da_block_size: None, - // No transaction count limit by default - max_tx_per_block: None, } } } impl MorphBuilderConfig { /// Creates a new [`MorphBuilderConfig`] with the specified parameters. - pub const fn new( - gas_limit: Option, - time_limit: Duration, - max_da_block_size: Option, - max_tx_per_block: Option, - ) -> Self { + pub const fn new(gas_limit: Option, time_limit: Duration) -> Self { Self { gas_limit, time_limit, - max_da_block_size, - max_tx_per_block, } } @@ -91,38 +58,19 @@ impl MorphBuilderConfig { self } - /// Sets the maximum DA block size. - pub const fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { - self.max_da_block_size = Some(max_da_block_size); - self - } - - /// Sets the maximum number of transactions per block. - pub const fn with_max_tx_per_block(mut self, max_tx_per_block: u64) -> Self { - self.max_tx_per_block = Some(max_tx_per_block); - self - } - /// Creates a [`PayloadBuildingBreaker`] for this configuration. pub(crate) fn breaker(&self, block_gas_limit: u64) -> PayloadBuildingBreaker { // Use configured gas limit or fall back to block gas limit let effective_gas_limit = self.gas_limit.unwrap_or(block_gas_limit); - PayloadBuildingBreaker::new( - self.time_limit, - effective_gas_limit, - self.max_da_block_size, - self.max_tx_per_block, - ) + PayloadBuildingBreaker::new(self.time_limit, effective_gas_limit) } } -/// Used in the payload builder to exit the transactions execution loop early. +/// Used in the [`super::MorphPayloadBuilder`] to exit the transactions execution loop early. /// -/// The breaker checks four conditions: +/// The breaker checks two conditions: /// 1. Time limit - stop if building takes too long /// 2. Gas limit - stop if remaining gas is insufficient for any transaction -/// 3. DA limit - stop if data availability size limit is reached -/// 4. Transaction count limit - stop if maximum transactions per block is reached #[derive(Debug, Clone)] pub struct PayloadBuildingBreaker { /// When the payload building started. @@ -131,26 +79,15 @@ pub struct PayloadBuildingBreaker { time_limit: Duration, /// Gas limit for the payload. gas_limit: u64, - /// Maximum DA block size. - max_da_block_size: Option, - /// Maximum number of transactions per block. - max_tx_per_block: Option, } impl PayloadBuildingBreaker { /// Creates a new [`PayloadBuildingBreaker`]. - fn new( - time_limit: Duration, - gas_limit: u64, - max_da_block_size: Option, - max_tx_per_block: Option, - ) -> Self { + fn new(time_limit: Duration, gas_limit: u64) -> Self { Self { start: Instant::now(), time_limit, gas_limit, - max_da_block_size, - max_tx_per_block, } } @@ -159,14 +96,7 @@ impl PayloadBuildingBreaker { /// Returns `true` if any of the following conditions are met: /// - Time limit has been exceeded /// - Gas limit has been reached (leaving room for at least one minimal transaction) - /// - DA size limit has been reached (leaving room for at least one minimal transaction) - /// - Transaction count limit has been reached - pub fn should_break( - &self, - cumulative_gas_used: u64, - cumulative_da_size_used: u64, - transaction_count: u64, - ) -> bool { + pub fn should_break(&self, cumulative_gas_used: u64) -> bool { // Check time limit if self.start.elapsed() >= self.time_limit { tracing::trace!( @@ -189,32 +119,6 @@ impl PayloadBuildingBreaker { return true; } - // Check DA size limit if configured - if let Some(max_size) = self.max_da_block_size - && cumulative_da_size_used > max_size.saturating_sub(MIN_TRANSACTION_DATA_SIZE) - { - tracing::trace!( - target: "payload_builder", - cumulative_da_size_used, - max_da_block_size = max_size, - "DA size limit reached" - ); - return true; - } - - // Check transaction count limit if configured - if let Some(max_count) = self.max_tx_per_block - && transaction_count >= max_count - { - tracing::trace!( - target: "payload_builder", - transaction_count, - max_tx_per_block = max_count, - "transaction count limit reached" - ); - return true; - } - false } @@ -233,41 +137,30 @@ mod tests { let config = MorphBuilderConfig::default(); assert_eq!(config.gas_limit, None); assert_eq!(config.time_limit, Duration::from_secs(1)); - assert_eq!(config.max_da_block_size, None); - assert_eq!(config.max_tx_per_block, None); } #[test] fn test_config_builder_pattern() { let config = MorphBuilderConfig::default() .with_gas_limit(20_000_000) - .with_time_limit(Duration::from_millis(500)) - .with_max_da_block_size(128 * 1024) - .with_max_tx_per_block(1000); + .with_time_limit(Duration::from_millis(500)); assert_eq!(config.gas_limit, Some(20_000_000)); assert_eq!(config.time_limit, Duration::from_millis(500)); - assert_eq!(config.max_da_block_size, Some(128 * 1024)); - assert_eq!(config.max_tx_per_block, Some(1000)); } #[test] fn test_breaker_should_break_on_time_limit() { - let breaker = PayloadBuildingBreaker::new( - Duration::from_millis(100), - 30_000_000, - Some(128 * 1024), - None, - ); + let breaker = PayloadBuildingBreaker::new(Duration::from_millis(100), 30_000_000); // Should not break immediately - assert!(!breaker.should_break(0, 0, 0)); + assert!(!breaker.should_break(0)); // Wait for time limit std::thread::sleep(Duration::from_millis(150)); // Should break now - assert!(breaker.should_break(0, 0, 0)); + assert!(breaker.should_break(0)); } #[test] @@ -276,63 +169,13 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // should_break returns true when cumulative_gas_used > threshold let gas_limit = 2 * MIN_TRANSACTION_GAS; - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit, None, None); + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit); // At threshold (21000), should NOT break (21000 > 21000 is false) - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); // Just over threshold, should break (21001 > 21000 is true) - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); - } - - #[test] - fn test_breaker_should_break_on_da_limit() { - // Set max_da = 2 * MIN_TRANSACTION_DATA_SIZE = 230 - // Threshold = 230 - 115 = 115 - let max_da_size = 2 * MIN_TRANSACTION_DATA_SIZE; - let breaker = PayloadBuildingBreaker::new( - Duration::from_secs(10), - 30_000_000, - Some(max_da_size), - None, - ); - - // At threshold (115), should NOT break (115 > 115 is false) - assert!(!breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE, 0)); - - // Just over threshold, should break (116 > 115 is true) - assert!(breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE + 1, 0)); - } - - #[test] - fn test_breaker_should_break_on_tx_count_limit() { - let breaker = - PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, Some(100)); - - // Below limit, should NOT break - assert!(!breaker.should_break(0, 0, 99)); - - // At limit, should break (>= comparison) - assert!(breaker.should_break(0, 0, 100)); - - // Above limit, should break - assert!(breaker.should_break(0, 0, 101)); - } - - #[test] - fn test_breaker_no_da_limit() { - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, None); - - // Should not break even with huge DA size when no limit is set - assert!(!breaker.should_break(0, u64::MAX, 0)); - } - - #[test] - fn test_breaker_no_tx_count_limit() { - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, None); - - // Should not break even with huge tx count when no limit is set - assert!(!breaker.should_break(0, 0, u64::MAX)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); } #[test] @@ -345,9 +188,9 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // At threshold, should NOT break - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); // Just over, should break - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); } #[test] @@ -361,8 +204,8 @@ mod tests { // Should use configured_limit, not block_gas_limit // Threshold = 42000 - 21000 = 21000 - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); // Verify it's not using block_gas_limit // If using block_gas_limit, threshold would be ~29,979,000 From 709e3eef092952d9836067da18b1663e87299484 Mon Sep 17 00:00:00 2001 From: panos Date: Thu, 13 Aug 2026 22:33:49 +0800 Subject: [PATCH 2/4] refactor: address review follow-ups on packing-cap removal Document that morph-geth still enforces maxTxPayloadBytesPerBlock in ValidateBody, so the rollout order is a hard constraint until its removal ships there. Guard the remaining gas check against overflow and reuse it on the L1 message path. Drop three tests that only asserted "does not panic", the unused MorphBuilderConfig::new, and correct the txpool size comment: reth's DEFAULT_MAX_TX_INPUT_BYTES is 128 KiB, not 120 KiB, and it applies to the full encoded length. --- crates/chainspec/src/genesis.rs | 15 ++++++++++++--- crates/node/src/args.rs | 5 ----- crates/node/src/components/pool.rs | 21 ++++++++++----------- crates/node/src/node.rs | 10 ---------- crates/payload/builder/src/builder.rs | 19 +++++++++++++++++-- crates/payload/builder/src/config.rs | 8 -------- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index bc68f3d1..f4563ea0 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -81,9 +81,18 @@ impl TryFrom<&OtherFields> for MorphHardforkInfo { /// The configuration for the Morph chain. /// -/// Unused genesis keys such as `maxTxPayloadBytesPerBlock` and `maxTxPerBlock` -/// are ignored. They are leftover zkEVM packing limits, not Morph consensus -/// parameters, and are not consumed by the payload builder. +/// The genesis keys `maxTxPayloadBytesPerBlock` (122880 on mainnet/hoodi) and +/// `maxTxPerBlock` are still present in the genesis JSON but are deliberately not +/// read here. They are leftover zkEVM packing limits; block size is now bounded by +/// header `gasLimit` and the payload builder time budget. +/// +/// Note that `maxTxPayloadBytesPerBlock` is *not* yet fully retired on the network: +/// morph-geth's `ValidateBody` (`core/block_validator.go`, via `IsValidBlockSize`) +/// still rejects blocks whose L2 payload exceeds it, and that check runs on the L2 +/// Engine API import path. Its removal is agreed but not yet released, so until +/// morph-geth has rolled out, a morph-reth sequencer must not produce blocks with +/// more than 122880 bytes of non-L1-message transaction payload — geth validators +/// would refuse to sign them. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphChainConfig { diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs index 619a4cd2..5623c5ad 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -22,11 +22,6 @@ mod tests { args: T, } - #[test] - fn test_default_args() { - let _args = CommandParser::::parse_from(["test"]).args; - } - #[test] fn reference_index_disable_flag_is_not_supported() { assert!( diff --git a/crates/node/src/components/pool.rs b/crates/node/src/components/pool.rs index 47a196dc..d3ec9799 100644 --- a/crates/node/src/components/pool.rs +++ b/crates/node/src/components/pool.rs @@ -106,15 +106,16 @@ mod tests { use alloy_primitives::{B256, Sealed, Signature, U256}; use morph_primitives::{MorphTxEnvelope, TxL1Msg}; use morph_txpool::MorphPooledTransaction; - use reth_transaction_pool::PoolTransaction; + use reth_transaction_pool::{PoolTransaction, validate::DEFAULT_MAX_TX_INPUT_BYTES}; #[tokio::test] async fn test_validate_oversized_transaction() { - // Test that transactions exceeding max_tx_input_bytes are rejected - // The default max_tx_input_bytes in reth is 120KB (122,880 bytes) - - // For this test, we create a mock pool that would reject oversized transactions - // The actual validation happens in the validator when checking encoded size + // `DEFAULT_MAX_TX_INPUT_BYTES` is 4 * TX_SLOT_BYTE_SIZE = 128 KiB. Despite the + // "input" in the name, for non-blob transactions the validator compares it against + // the full 2718-encoded length, which matches go-ethereum's `txMaxSize`. + // + // This test only covers the size the pool transaction reports; the rejection itself + // lives in the validator, which needs a full provider to exercise. // Create a legacy transaction let tx = MorphTxEnvelope::Legacy(Signed::new_unchecked( @@ -126,15 +127,13 @@ mod tests { Default::default(), )); - // Create a pool transaction with an encoded length exceeding the limit (121KB > 120KB) + // Create a pool transaction one byte over the limit let pool_tx = MorphPooledTransaction::new( Recovered::new_unchecked(tx, Default::default()), - 121 * 1024, + DEFAULT_MAX_TX_INPUT_BYTES + 1, ); - // Verify the encoded length is larger than the limit - assert_eq!(pool_tx.encoded_length(), 121 * 1024); - assert!(pool_tx.encoded_length() > 120 * 1024); + assert!(pool_tx.encoded_length() > DEFAULT_MAX_TX_INPUT_BYTES); } #[tokio::test] diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index a8aeb873..27f9cdfc 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -227,16 +227,6 @@ mod tests { use morph_chainspec::MORPH_HOODI; use reth_payload_primitives::PayloadAttributesBuilder; - #[test] - fn morph_node_default() { - let _node = MorphNode::default(); - } - - #[test] - fn morph_node_new_with_args() { - let _node = MorphNode::new(super::super::args::MorphArgs::default()); - } - #[test] fn payload_attributes_builder_produces_valid_attributes() { let chain_spec = MORPH_HOODI.clone(); diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 00571e17..55718153 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -361,7 +361,7 @@ impl MorphPayloadBuilderCtx { let tx_gas = recovered_tx.gas_limit(); // Check if adding this transaction would exceed block gas limit - if info.cumulative_gas_used + tx_gas > block_gas_limit { + if info.is_tx_over_limits(tx_gas, block_gas_limit) { tracing::warn!( target: "payload_builder", tx_index = tx_idx, @@ -648,8 +648,13 @@ impl ExecutionInfo { } /// Returns true if the transaction would exceed remaining block gas. + /// + /// An overflowing sum counts as over the limit: wrapping would otherwise let a + /// transaction with an absurd gas limit through and produce an invalid block. fn is_tx_over_limits(&self, tx_gas_limit: u64, block_gas_limit: u64) -> bool { - self.cumulative_gas_used + tx_gas_limit > block_gas_limit + self.cumulative_gas_used + .checked_add(tx_gas_limit) + .is_none_or(|total_gas| total_gas > block_gas_limit) } } @@ -985,6 +990,16 @@ mod tests { assert!(!info.is_tx_over_limits(0, 0)); } + #[test] + fn test_is_tx_over_limits_gas_sum_overflow() { + let info = ExecutionInfo { + cumulative_gas_used: 1, + ..Default::default() + }; + // Wrapping would yield 0 and wrongly report "fits"; overflow must count as over. + assert!(info.is_tx_over_limits(u64::MAX, 30_000_000)); + } + // ========================================================================= // MorphPayloadBuilder constructor tests // ========================================================================= diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 3e36a749..83e3a80f 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -38,14 +38,6 @@ impl Default for MorphBuilderConfig { } impl MorphBuilderConfig { - /// Creates a new [`MorphBuilderConfig`] with the specified parameters. - pub const fn new(gas_limit: Option, time_limit: Duration) -> Self { - Self { - gas_limit, - time_limit, - } - } - /// Sets the gas limit. pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { self.gas_limit = Some(gas_limit); From 4ee8b4d89ab7846af86764d694131b84e07de9ef Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 19 Aug 2026 12:06:34 +0800 Subject: [PATCH 3/4] fix: keep a 720 KiB per-block DA packing cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 6-blob batch holds 761856 usable bytes uncompressed. Size the sequencer payload cap at 120 KiB × 6 so a single L2 block still fits without splitting, and drop only the leftover zkEVM tx-count limit. --- README.md | 1 + crates/chainspec/src/genesis.rs | 15 ++-- crates/node/src/args.rs | 79 +++++++++++++++--- crates/node/src/components/payload.rs | 6 ++ crates/node/src/node.rs | 4 +- crates/payload/builder/src/builder.rs | 113 ++++++++++++++++++++------ crates/payload/builder/src/config.rs | 97 ++++++++++++++++++---- 7 files changed, 254 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 2c82e95a..620173bd 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ openssl rand -hex 32 > jwt.hex | Flag | Default | Description | |------|---------|-------------| +| `--morph.max-tx-payload-bytes` | 737280 (720 KiB) | Maximum L2 tx payload bytes per block (fits one uncompressed 6-blob batch) | | `--rpc.eth-proof-window` | 0 (disabled) | Max historical blocks for `eth_getProof` (up to 1209600) | ### Running Tests diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index f4563ea0..537c2341 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -83,16 +83,13 @@ impl TryFrom<&OtherFields> for MorphHardforkInfo { /// /// The genesis keys `maxTxPayloadBytesPerBlock` (122880 on mainnet/hoodi) and /// `maxTxPerBlock` are still present in the genesis JSON but are deliberately not -/// read here. They are leftover zkEVM packing limits; block size is now bounded by -/// header `gasLimit` and the payload builder time budget. +/// read here. Sequencer packing uses `--morph.max-tx-payload-bytes` (default +/// 720 KiB = 120 KiB × 6 blobs) rather than the leftover zkEVM genesis field. /// -/// Note that `maxTxPayloadBytesPerBlock` is *not* yet fully retired on the network: -/// morph-geth's `ValidateBody` (`core/block_validator.go`, via `IsValidBlockSize`) -/// still rejects blocks whose L2 payload exceeds it, and that check runs on the L2 -/// Engine API import path. Its removal is agreed but not yet released, so until -/// morph-geth has rolled out, a morph-reth sequencer must not produce blocks with -/// more than 122880 bytes of non-L1-message transaction payload — geth validators -/// would refuse to sign them. +/// Note that morph-geth's `ValidateBody` (`core/block_validator.go`, via +/// `IsValidBlockSize`) still rejects L2 payloads above the genesis 122880 +/// value. Until that check is raised or removed, a mixed-client sequencer +/// must not produce larger blocks or geth validators will refuse to sign them. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphChainConfig { diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs index 5623c5ad..ed56fe8e 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -2,14 +2,45 @@ use clap::Args; +/// Default maximum L2 transaction payload bytes per block (720 KiB). +/// +/// `720 KiB = 120 KiB × 6`. A Morph batch can carry up to 6 EIP-4844 blobs. +/// Each blob's usable payload is `4096 × 31 = 126_976` bytes (~124 KiB), so +/// six blobs hold 761_856 bytes uncompressed. 120 KiB per blob is the +/// historical headroom under that usable size; six of them stay under the +/// uncompressed 6-blob budget and do not require the submitter to split a +/// single L2 block. +pub const MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES: u64 = 720 * 1024; + /// Morph-specific CLI arguments. /// -/// Extends the standard reth CLI. Currently has no Morph-only flags: block packing is -/// bounded by header `gasLimit` and the payload builder time budget. +/// Block packing is bounded by header `gasLimit`, the payload builder time +/// budget, and `--morph.max-tx-payload-bytes` (the uncompressed L2 payload +/// that must fit in one 6-blob batch). /// /// Note: Block building deadline is configured via reth's built-in `--builder.deadline` flag. -#[derive(Debug, Clone, Args, Default)] -pub struct MorphArgs {} +#[derive(Debug, Clone, Args)] +#[command(next_help_heading = "Morph")] +pub struct MorphArgs { + /// Maximum L2 transaction payload bytes per block (L1 messages excluded). + /// + /// Default: 737280 bytes (720 KiB), sized so one L2 block fits in a + /// 6-blob batch even without compression. + #[arg( + long = "morph.max-tx-payload-bytes", + value_name = "BYTES", + default_value_t = MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + )] + pub max_tx_payload_bytes: u64, +} + +impl Default for MorphArgs { + fn default() -> Self { + Self { + max_tx_payload_bytes: MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES, + } + } +} #[cfg(test)] mod tests { @@ -22,6 +53,27 @@ mod tests { args: T, } + #[test] + fn test_default_args() { + let args = CommandParser::::parse_from(["test"]).args; + assert_eq!( + args.max_tx_payload_bytes, + MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + ); + assert_eq!(args.max_tx_payload_bytes, 720 * 1024); + } + + #[test] + fn test_custom_payload_bytes() { + let args = CommandParser::::parse_from([ + "test", + "--morph.max-tx-payload-bytes", + "100000", + ]) + .args; + assert_eq!(args.max_tx_payload_bytes, 100000); + } + #[test] fn reference_index_disable_flag_is_not_supported() { assert!( @@ -35,18 +87,19 @@ mod tests { } #[test] - fn unused_packing_flags_are_not_supported() { - assert!( - CommandParser::::try_parse_from([ - "test", - "--morph.max-tx-payload-bytes", - "1" - ]) - .is_err() - ); + fn unused_tx_count_flag_is_not_supported() { assert!( CommandParser::::try_parse_from(["test", "--morph.max-tx-per-block", "1"]) .is_err() ); } + + #[test] + fn test_default_trait_impl() { + let args = MorphArgs::default(); + assert_eq!( + args.max_tx_payload_bytes, + MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + ); + } } diff --git a/crates/node/src/components/payload.rs b/crates/node/src/components/payload.rs index fadff017..37436ca5 100644 --- a/crates/node/src/components/payload.rs +++ b/crates/node/src/components/payload.rs @@ -26,6 +26,12 @@ impl MorphPayloadBuilderBuilder { pub const fn new(config: MorphBuilderConfig) -> Self { Self { config } } + + /// Sets the maximum DA block size (transaction payload bytes per block). + pub fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { + self.config = self.config.with_max_da_block_size(max_da_block_size); + self + } } impl diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 27f9cdfc..b73f5073 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -118,7 +118,9 @@ where type AddOns = MorphAddOns>; fn components_builder(&self) -> Self::ComponentsBuilder { - Self::components(MorphBuilderConfig::default()) + Self::components( + MorphBuilderConfig::default().with_max_da_block_size(self.args.max_tx_payload_bytes), + ) } fn add_ons(&self) -> Self::AddOns { diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 55718153..c0d0c13a 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -360,8 +360,9 @@ impl MorphPayloadBuilderCtx { let tx_gas = recovered_tx.gas_limit(); - // Check if adding this transaction would exceed block gas limit - if info.is_tx_over_limits(tx_gas, block_gas_limit) { + // Check if adding this transaction would exceed block gas limit. + // L1 messages are excluded from DA payload size (prepaid on L1). + if info.is_tx_over_limits(tx_gas, 0, block_gas_limit) { tracing::warn!( target: "payload_builder", tx_index = tx_idx, @@ -498,11 +499,12 @@ impl MorphPayloadBuilderCtx { return Ok(Some(())); } - // Check if the breaker triggers (time or gas limits) - if breaker.should_break(info.cumulative_gas_used) { + // Check if the breaker triggers (time, gas, or DA limits) + if breaker.should_break(info.cumulative_gas_used, info.cumulative_da_bytes_used) { tracing::debug!( target: "payload_builder", cumulative_gas_used = info.cumulative_gas_used, + cumulative_da_bytes_used = info.cumulative_da_bytes_used, transaction_count = info.transaction_count, elapsed = ?breaker.elapsed(), "breaker triggered, stopping pool transaction execution" @@ -530,15 +532,18 @@ impl MorphPayloadBuilderCtx { continue; } - // Skip transactions that cannot fit in remaining block gas. - if info.is_tx_over_limits(tx.gas_limit(), block_gas_limit) { + // Skip transactions that cannot fit in remaining block gas or DA size. + let tx_size = tx.encode_2718_len() as u64; + if info.is_tx_over_limits(tx.gas_limit(), tx_size, block_gas_limit) { tracing::debug!( target: "payload_builder", signer = %tx.signer(), nonce = tx.nonce(), tx_gas_limit = tx.gas_limit(), + tx_size, block_gas_limit, - "pool transaction exceeds remaining block gas; skipping" + max_da_block_size = self.builder_config.max_da_block_size, + "pool transaction exceeds remaining block gas or DA size; skipping" ); best_txs.mark_invalid(tx.signer(), tx.nonce()); continue; @@ -607,6 +612,7 @@ impl MorphPayloadBuilderCtx { // Update execution info info.cumulative_gas_used += gas_used; + info.cumulative_da_bytes_used += tx_size; info.transaction_count += 1; // Calculate fees: effective_tip * gas_used @@ -628,33 +634,53 @@ impl MorphPayloadBuilderCtx { struct ExecutionInfo { /// Cumulative gas used by all executed transactions. cumulative_gas_used: u64, + /// Cumulative encoded L2 transaction bytes counted toward the DA packing cap. + /// L1 messages are not included. + cumulative_da_bytes_used: u64, /// Total fees collected from executed transactions. total_fees: U256, /// Next L1 message queue index. next_l1_message_index: u64, /// Number of transactions executed (including both sequencer and pool transactions). transaction_count: u64, + /// Maximum DA block size from the builder config. + max_da_block_size: Option, } impl ExecutionInfo { /// Creates a new [`ExecutionInfo`] with the initial next L1 message index from parent. - const fn new(next_l1_message_index: u64) -> Self { + const fn new(next_l1_message_index: u64, max_da_block_size: Option) -> Self { Self { cumulative_gas_used: 0, + cumulative_da_bytes_used: 0, total_fees: U256::ZERO, next_l1_message_index, transaction_count: 0, + max_da_block_size, } } - /// Returns true if the transaction would exceed remaining block gas. + /// Returns true if the transaction would exceed remaining block gas or DA size. /// /// An overflowing sum counts as over the limit: wrapping would otherwise let a /// transaction with an absurd gas limit through and produce an invalid block. - fn is_tx_over_limits(&self, tx_gas_limit: u64, block_gas_limit: u64) -> bool { - self.cumulative_gas_used + fn is_tx_over_limits(&self, tx_gas_limit: u64, tx_size: u64, block_gas_limit: u64) -> bool { + if self + .cumulative_gas_used .checked_add(tx_gas_limit) .is_none_or(|total_gas| total_gas > block_gas_limit) + { + return true; + } + + if let Some(da_limit) = self.max_da_block_size { + return self + .cumulative_da_bytes_used + .checked_add(tx_size) + .is_none_or(|total_da| total_da > da_limit); + } + + false } } @@ -726,7 +752,10 @@ where })?; // Initialize next_l1_message_index from parent header - let mut info = ExecutionInfo::new(ctx.parent().next_l1_msg_index); + let mut info = ExecutionInfo::new( + ctx.parent().next_l1_msg_index, + ctx.builder_config.max_da_block_size, + ); let base_fee = builder.evm().block().basefee(); let block_gas_limit = builder.evm().block().gas_limit(); @@ -759,6 +788,7 @@ where target: "payload_builder", elapsed = ?breaker.elapsed(), cumulative_gas_used = info.cumulative_gas_used, + cumulative_da_bytes_used = info.cumulative_da_bytes_used, tx_count = executed_txs.len(), "breaker stopped pool execution, finalizing payload" ); @@ -905,29 +935,33 @@ mod tests { fn test_execution_info_default() { let info = ExecutionInfo::default(); assert_eq!(info.cumulative_gas_used, 0); + assert_eq!(info.cumulative_da_bytes_used, 0); assert_eq!(info.total_fees, U256::ZERO); assert_eq!(info.next_l1_message_index, 0); assert_eq!(info.transaction_count, 0); + assert_eq!(info.max_da_block_size, None); } #[test] fn test_execution_info_new_with_l1_index() { - let info = ExecutionInfo::new(42); + let info = ExecutionInfo::new(42, Some(720 * 1024)); assert_eq!(info.next_l1_message_index, 42); assert_eq!(info.cumulative_gas_used, 0); + assert_eq!(info.cumulative_da_bytes_used, 0); assert_eq!(info.total_fees, U256::ZERO); assert_eq!(info.transaction_count, 0); + assert_eq!(info.max_da_block_size, Some(720 * 1024)); } #[test] fn test_execution_info_new_with_zero_index() { - let info = ExecutionInfo::new(0); + let info = ExecutionInfo::new(0, None); assert_eq!(info.next_l1_message_index, 0); } #[test] fn test_execution_info_new_with_max_index() { - let info = ExecutionInfo::new(u64::MAX); + let info = ExecutionInfo::new(u64::MAX, None); assert_eq!(info.next_l1_message_index, u64::MAX); } @@ -942,7 +976,7 @@ mod tests { ..Default::default() }; // tx_gas + cumulative = 100_000 + 21_000 = 121_000, block limit = 30_000_000 - assert!(!info.is_tx_over_limits(21_000, 30_000_000)); + assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000)); } #[test] @@ -952,7 +986,7 @@ mod tests { ..Default::default() }; // tx_gas + cumulative = 29_990_000 + 21_000 = 30_011_000 > 30_000_000 - assert!(info.is_tx_over_limits(21_000, 30_000_000)); + assert!(info.is_tx_over_limits(21_000, 100, 30_000_000)); } #[test] @@ -963,7 +997,7 @@ mod tests { }; // tx_gas + cumulative = 29_979_000 + 21_000 = 30_000_000 == block limit // Uses > comparison, so exactly at limit is NOT over - assert!(!info.is_tx_over_limits(21_000, 30_000_000)); + assert!(!info.is_tx_over_limits(21_000, 100, 30_000_000)); } #[test] @@ -973,21 +1007,21 @@ mod tests { ..Default::default() }; // tx_gas + cumulative = 29_979_001 + 21_000 = 30_000_001 > 30_000_000 - assert!(info.is_tx_over_limits(21_000, 30_000_000)); + assert!(info.is_tx_over_limits(21_000, 100, 30_000_000)); } #[test] fn test_is_tx_over_limits_zero_gas_tx() { let info = ExecutionInfo::default(); - assert!(!info.is_tx_over_limits(0, 30_000_000)); + assert!(!info.is_tx_over_limits(0, 0, 30_000_000)); } #[test] fn test_is_tx_over_limits_zero_block_gas_limit() { let info = ExecutionInfo::default(); - assert!(info.is_tx_over_limits(1, 0)); + assert!(info.is_tx_over_limits(1, 0, 0)); // 0 > 0 is false - assert!(!info.is_tx_over_limits(0, 0)); + assert!(!info.is_tx_over_limits(0, 0, 0)); } #[test] @@ -997,7 +1031,40 @@ mod tests { ..Default::default() }; // Wrapping would yield 0 and wrongly report "fits"; overflow must count as over. - assert!(info.is_tx_over_limits(u64::MAX, 30_000_000)); + assert!(info.is_tx_over_limits(u64::MAX, 0, 30_000_000)); + } + + #[test] + fn test_is_tx_over_limits_exceeds_da_limit() { + let info = ExecutionInfo { + cumulative_da_bytes_used: 700_000, + max_da_block_size: Some(720 * 1024), + ..Default::default() + }; + // 700_000 + 40_000 = 740_000 > 737_280 + assert!(info.is_tx_over_limits(21_000, 40_000, 30_000_000)); + // 700_000 + 10_000 = 710_000 < 737_280 + assert!(!info.is_tx_over_limits(21_000, 10_000, 30_000_000)); + } + + #[test] + fn test_is_tx_over_limits_da_limit_none_ignores_da() { + let info = ExecutionInfo { + cumulative_da_bytes_used: u64::MAX, + max_da_block_size: None, + ..Default::default() + }; + assert!(!info.is_tx_over_limits(21_000, 1_000, 30_000_000)); + } + + #[test] + fn test_is_tx_over_limits_da_sum_overflow() { + let info = ExecutionInfo { + cumulative_da_bytes_used: 1, + max_da_block_size: Some(720 * 1024), + ..Default::default() + }; + assert!(info.is_tx_over_limits(21_000, u64::MAX, 30_000_000)); } // ========================================================================= diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 83e3a80f..c0a997eb 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -4,6 +4,10 @@ use core::time::Duration; use reth_chainspec::MIN_TRANSACTION_GAS; use reth_primitives_traits::FastInstant as Instant; +/// Minimal data bytes size per transaction. +/// This is a conservative estimate for the minimum encoded transaction size. +pub(crate) const MIN_TRANSACTION_DATA_SIZE: u64 = 115; + /// Settings for the Morph payload builder. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MorphBuilderConfig { @@ -25,6 +29,16 @@ pub struct MorphBuilderConfig { /// once this duration has elapsed since the start of building. /// This ensures timely block production even with large mempools. pub time_limit: Duration, + + /// Maximum total data availability size for a block. + /// + /// L2 transactions are published to L1 in EIP-4844 blobs. This limit is the + /// uncompressed L2 tx payload (L1 messages excluded) that must fit in one + /// 6-blob batch so the submitter never has to split a single L2 block. + /// If `None`, no DA limit is enforced. + /// + /// This corresponds to the `--morph.max-tx-payload-bytes` CLI flag. + pub max_da_block_size: Option, } impl Default for MorphBuilderConfig { @@ -33,6 +47,8 @@ impl Default for MorphBuilderConfig { gas_limit: None, // Default to 1 second - leaves time for consensus time_limit: Duration::from_secs(1), + // No DA limit by default; the node wires the CLI default. + max_da_block_size: None, } } } @@ -50,19 +66,26 @@ impl MorphBuilderConfig { self } + /// Sets the maximum DA block size. + pub const fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { + self.max_da_block_size = Some(max_da_block_size); + self + } + /// Creates a [`PayloadBuildingBreaker`] for this configuration. pub(crate) fn breaker(&self, block_gas_limit: u64) -> PayloadBuildingBreaker { // Use configured gas limit or fall back to block gas limit let effective_gas_limit = self.gas_limit.unwrap_or(block_gas_limit); - PayloadBuildingBreaker::new(self.time_limit, effective_gas_limit) + PayloadBuildingBreaker::new(self.time_limit, effective_gas_limit, self.max_da_block_size) } } /// Used in the [`super::MorphPayloadBuilder`] to exit the transactions execution loop early. /// -/// The breaker checks two conditions: +/// The breaker checks three conditions: /// 1. Time limit - stop if building takes too long /// 2. Gas limit - stop if remaining gas is insufficient for any transaction +/// 3. DA limit - stop if data availability size limit is reached #[derive(Debug, Clone)] pub struct PayloadBuildingBreaker { /// When the payload building started. @@ -71,15 +94,18 @@ pub struct PayloadBuildingBreaker { time_limit: Duration, /// Gas limit for the payload. gas_limit: u64, + /// Maximum DA block size. + max_da_block_size: Option, } impl PayloadBuildingBreaker { /// Creates a new [`PayloadBuildingBreaker`]. - fn new(time_limit: Duration, gas_limit: u64) -> Self { + fn new(time_limit: Duration, gas_limit: u64, max_da_block_size: Option) -> Self { Self { start: Instant::now(), time_limit, gas_limit, + max_da_block_size, } } @@ -88,7 +114,8 @@ impl PayloadBuildingBreaker { /// Returns `true` if any of the following conditions are met: /// - Time limit has been exceeded /// - Gas limit has been reached (leaving room for at least one minimal transaction) - pub fn should_break(&self, cumulative_gas_used: u64) -> bool { + /// - DA size limit has been reached (leaving room for at least one minimal transaction) + pub fn should_break(&self, cumulative_gas_used: u64, cumulative_da_size_used: u64) -> bool { // Check time limit if self.start.elapsed() >= self.time_limit { tracing::trace!( @@ -111,6 +138,19 @@ impl PayloadBuildingBreaker { return true; } + // Check DA size limit if configured + if let Some(max_size) = self.max_da_block_size + && cumulative_da_size_used > max_size.saturating_sub(MIN_TRANSACTION_DATA_SIZE) + { + tracing::trace!( + target: "payload_builder", + cumulative_da_size_used, + max_da_block_size = max_size, + "DA size limit reached" + ); + return true; + } + false } @@ -129,30 +169,34 @@ mod tests { let config = MorphBuilderConfig::default(); assert_eq!(config.gas_limit, None); assert_eq!(config.time_limit, Duration::from_secs(1)); + assert_eq!(config.max_da_block_size, None); } #[test] fn test_config_builder_pattern() { let config = MorphBuilderConfig::default() .with_gas_limit(20_000_000) - .with_time_limit(Duration::from_millis(500)); + .with_time_limit(Duration::from_millis(500)) + .with_max_da_block_size(720 * 1024); assert_eq!(config.gas_limit, Some(20_000_000)); assert_eq!(config.time_limit, Duration::from_millis(500)); + assert_eq!(config.max_da_block_size, Some(720 * 1024)); } #[test] fn test_breaker_should_break_on_time_limit() { - let breaker = PayloadBuildingBreaker::new(Duration::from_millis(100), 30_000_000); + let breaker = + PayloadBuildingBreaker::new(Duration::from_millis(100), 30_000_000, Some(720 * 1024)); // Should not break immediately - assert!(!breaker.should_break(0)); + assert!(!breaker.should_break(0, 0)); // Wait for time limit std::thread::sleep(Duration::from_millis(150)); // Should break now - assert!(breaker.should_break(0)); + assert!(breaker.should_break(0, 0)); } #[test] @@ -161,13 +205,36 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // should_break returns true when cumulative_gas_used > threshold let gas_limit = 2 * MIN_TRANSACTION_GAS; - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit); + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit, None); // At threshold (21000), should NOT break (21000 > 21000 is false) - assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); // Just over threshold, should break (21001 > 21000 is true) - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + } + + #[test] + fn test_breaker_should_break_on_da_limit() { + // Set max_da = 2 * MIN_TRANSACTION_DATA_SIZE = 230 + // Threshold = 230 - 115 = 115 + let max_da_size = 2 * MIN_TRANSACTION_DATA_SIZE; + let breaker = + PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, Some(max_da_size)); + + // At threshold (115), should NOT break (115 > 115 is false) + assert!(!breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE)); + + // Just over threshold, should break (116 > 115 is true) + assert!(breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE + 1)); + } + + #[test] + fn test_breaker_no_da_limit() { + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None); + + // Should not break even with huge DA size when no limit is set + assert!(!breaker.should_break(0, u64::MAX)); } #[test] @@ -180,9 +247,9 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // At threshold, should NOT break - assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); // Just over, should break - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); } #[test] @@ -196,8 +263,8 @@ mod tests { // Should use configured_limit, not block_gas_limit // Threshold = 42000 - 21000 = 21000 - assert!(!breaker.should_break(MIN_TRANSACTION_GAS)); - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); // Verify it's not using block_gas_limit // If using block_gas_limit, threshold would be ~29,979,000 From 9e32c7ebc7db209f7fe4f1c4211a46f4e279ab0c Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 19 Aug 2026 15:16:25 +0800 Subject: [PATCH 4/4] fix: enforce 720 KiB L2 payload cap on import Match geth ValidateBody so followers reject oversized blocks, using the same binary constant as sequencer packing (L1 messages excluded). --- Cargo.lock | 1 + crates/chainspec/src/constants.rs | 14 +++ crates/chainspec/src/genesis.rs | 10 +-- crates/consensus/Cargo.toml | 1 + crates/consensus/src/error.rs | 12 +++ crates/consensus/src/validation.rs | 134 +++++++++++++++++++++++++++-- crates/node/src/args.rs | 7 +- 7 files changed, 168 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a3b9dae..7c7cdfc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4939,6 +4939,7 @@ name = "morph-consensus" version = "1.1.0" dependencies = [ "alloy-consensus", + "alloy-eips", "alloy-evm", "alloy-genesis", "alloy-primitives", diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs index 56b98755..f24a18fa 100644 --- a/crates/chainspec/src/constants.rs +++ b/crates/chainspec/src/constants.rs @@ -12,6 +12,14 @@ pub const MORPH_HOODI_CHAIN_ID: u64 = 2910; /// The sequencer has the right to set any base fee below `MORPH_MAX_BASE_FEE`. pub const MORPH_BASE_FEE: u64 = 1_000_000; +/// Maximum L2 transaction payload bytes per block (L1 messages excluded). +/// +/// Matches morph-geth `params.MorphMaxTxPayloadBytesPerBlock` (`720 * 1024`). +/// `720 KiB = 120 KiB × 6`, sized so one uncompressed L2 block fits in a 6-blob +/// batch (`6 × 4096 × 31 = 761_856` usable bytes). Enforced on import by Morph +/// consensus and used as the sequencer packing default. +pub const MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK: u64 = 720 * 1024; + /// Default priority fee returned by `eth_maxPriorityFeePerGas` when the gas /// price oracle has no usable block samples (cold start or empty/zero-tip /// blocks, the common case on Morph L2). @@ -110,4 +118,10 @@ mod tests { fn test_base_fee() { assert_eq!(MORPH_BASE_FEE, 1_000_000); } + + #[test] + fn test_max_tx_payload_bytes_per_block() { + assert_eq!(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, 720 * 1024); + assert_eq!(MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, 737_280); + } } diff --git a/crates/chainspec/src/genesis.rs b/crates/chainspec/src/genesis.rs index 537c2341..98a2b93e 100644 --- a/crates/chainspec/src/genesis.rs +++ b/crates/chainspec/src/genesis.rs @@ -84,12 +84,12 @@ impl TryFrom<&OtherFields> for MorphHardforkInfo { /// The genesis keys `maxTxPayloadBytesPerBlock` (122880 on mainnet/hoodi) and /// `maxTxPerBlock` are still present in the genesis JSON but are deliberately not /// read here. Sequencer packing uses `--morph.max-tx-payload-bytes` (default -/// 720 KiB = 120 KiB × 6 blobs) rather than the leftover zkEVM genesis field. +/// [`crate::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`]) rather than the leftover +/// zkEVM genesis field. /// -/// Note that morph-geth's `ValidateBody` (`core/block_validator.go`, via -/// `IsValidBlockSize`) still rejects L2 payloads above the genesis 122880 -/// value. Until that check is raised or removed, a mixed-client sequencer -/// must not produce larger blocks or geth validators will refuse to sign them. +/// Import-time body validation in morph-geth (`IsValidBlockSize`) and morph-reth +/// (`MorphConsensus::validate_block_pre_execution`) both enforce that same +/// 720 KiB binary constant, not the stored genesis 122880. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MorphChainConfig { diff --git a/crates/consensus/Cargo.toml b/crates/consensus/Cargo.toml index 880abd21..5f40f368 100644 --- a/crates/consensus/Cargo.toml +++ b/crates/consensus/Cargo.toml @@ -23,6 +23,7 @@ reth-primitives-traits.workspace = true # Alloy alloy-consensus.workspace = true +alloy-eips.workspace = true alloy-evm.workspace = true alloy-primitives.workspace = true alloy-rlp.workspace = true diff --git a/crates/consensus/src/error.rs b/crates/consensus/src/error.rs index 280f7e09..42896bd8 100644 --- a/crates/consensus/src/error.rs +++ b/crates/consensus/src/error.rs @@ -64,6 +64,18 @@ pub enum MorphConsensusError { /// Withdrawals are not empty. #[error("Withdrawals are not empty")] WithdrawalsNonEmpty, + + /// L2 transaction payload exceeds the per-block DA cap. + /// + /// Matches go-ethereum `ErrInvalidBlockPayloadSize`. L1 messages are + /// excluded from `size`. + #[error("invalid block payload size: {size} exceeds limit {limit}")] + InvalidBlockPayloadSize { + /// Encoded L2 payload bytes (EIP-2718, L1 messages excluded). + size: u64, + /// Maximum allowed payload bytes. + limit: u64, + }, } impl From for MorphConsensusError { diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index cd12183c..44cdc161 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -27,6 +27,8 @@ //! - No uncle blocks allowed //! - Withdrawals field must not be present //! - Transaction root must be valid +//! - L2 transaction payload (EIP-2718 encoded, L1 messages excluded) must not +//! exceed [`morph_chainspec::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`] //! //! ## Post-Execution Validation //! @@ -36,9 +38,10 @@ //! use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; +use alloy_eips::eip2718::Encodable2718; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; -use morph_chainspec::{MorphChainSpec, MorphHardforks}; +use morph_chainspec::{MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, MorphChainSpec, MorphHardforks}; use morph_primitives::{ Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1, @@ -271,7 +274,9 @@ impl Consensus for MorphConsensus { /// 2. **Ommers Hash**: Must be the empty ommer root hash /// 3. **Transaction Root**: Must be valid /// 4. **Withdrawals**: Must be empty (Morph L2 doesn't support withdrawals) - /// 5. **L1 Messages**: Must be ordered correctly (sequential queue indices, L1 before L2) + /// 5. **L2 Payload Size**: Encoded L2 txs (L1 messages excluded) must not + /// exceed [`MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`] + /// 6. **L1 Messages**: Must be ordered correctly (sequential queue indices, L1 before L2) fn validate_block_pre_execution( &self, block: &SealedBlock, @@ -305,6 +310,9 @@ impl Consensus for MorphConsensus { )); } + // Matches go-ethereum's BlockValidator.ValidateBody() → IsValidBlockSize(). + validate_l2_tx_payload_size(&block.body().transactions)?; + // Validate MorphTx activation, version and field constraints. // Matches go-ethereum's BlockValidator.ValidateBody() → ValidateMorphTxVersion(). let is_emerald = self @@ -467,6 +475,37 @@ fn validate_against_parent_gas_limit( Ok(()) } +// ============================================================================ +// L2 Payload Size Validation +// ============================================================================ + +/// Sum of EIP-2718 encoded L2 transaction bytes. +/// +/// Matches go-ethereum `Block.PayloadSize()`: L1 messages are excluded because +/// their calldata is already published on L1. +fn l2_tx_payload_bytes(txs: &[MorphTxEnvelope]) -> u64 { + txs.iter() + .filter(|tx| !tx.is_l1_msg()) + .map(|tx| tx.encode_2718_len() as u64) + .fold(0, u64::saturating_add) +} + +/// Rejects blocks whose L2 payload exceeds [`MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`]. +/// +/// Matches go-ethereum `MorphConfig.IsValidBlockSize` (`size <= limit`). +fn validate_l2_tx_payload_size(txs: &[MorphTxEnvelope]) -> Result<(), ConsensusError> { + let size = l2_tx_payload_bytes(txs); + if size > MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK { + return Err(ConsensusError::other( + MorphConsensusError::InvalidBlockPayloadSize { + size, + limit: MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK, + }, + )); + } + Ok(()) +} + // ============================================================================ // L1 Message Validation // ============================================================================ @@ -766,13 +805,17 @@ mod tests { } fn create_l1_msg_tx(queue_index: u64) -> MorphTxEnvelope { + create_l1_msg_tx_with_input(queue_index, Bytes::default()) + } + + fn create_l1_msg_tx_with_input(queue_index: u64, input: Bytes) -> MorphTxEnvelope { use alloy_consensus::Sealed; let tx = TxL1Msg { queue_index, gas_limit: 21000, to: Address::ZERO, value: U256::ZERO, - input: Bytes::default(), + input, sender: Address::ZERO, }; // L1 messages have no signature - use Sealed instead of Signed @@ -780,8 +823,16 @@ mod tests { } fn create_regular_tx() -> MorphTxEnvelope { + create_legacy_tx_with_input(Bytes::default()) + } + + fn create_legacy_tx_with_input(input: Bytes) -> MorphTxEnvelope { use alloy_consensus::TxLegacy; - let tx = TxLegacy::default(); + let tx = TxLegacy { + gas_limit: 1_000_000, + input, + ..Default::default() + }; let sig = Signature::new(U256::ZERO, U256::ZERO, false); MorphTxEnvelope::Legacy(Signed::new_unchecked(tx, sig, B256::ZERO)) } @@ -789,17 +840,26 @@ mod tests { fn create_sealed_block( timestamp: u64, transactions: Vec, + ) -> SealedBlock { + create_sealed_block_with_next_l1(timestamp, transactions, 0) + } + + fn create_sealed_block_with_next_l1( + timestamp: u64, + transactions: Vec, + next_l1_msg_index: u64, ) -> SealedBlock { use alloy_consensus::proofs::calculate_transaction_root; use reth_primitives_traits::Block as _; let transactions_root = calculate_transaction_root(&transactions); - let header = create_morph_header(Header { + let mut header = create_morph_header(Header { timestamp, transactions_root, ommers_hash: EMPTY_OMMER_ROOT_HASH, ..Default::default() }); + header.next_l1_msg_index = next_l1_msg_index; Block::new( header, BlockBody { @@ -1865,6 +1925,70 @@ mod tests { ); } + #[test] + fn test_l2_tx_payload_bytes_excludes_l1_messages() { + let l1 = create_l1_msg_tx_with_input( + 0, + Bytes::from(vec![1u8; MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK as usize + 1]), + ); + let l2 = create_regular_tx(); + + assert_eq!(l2_tx_payload_bytes(std::slice::from_ref(&l1)), 0); + assert_eq!( + l2_tx_payload_bytes(&[l1, l2.clone()]), + l2_tx_payload_bytes(std::slice::from_ref(&l2)) + ); + } + + #[test] + fn test_validate_block_pre_execution_accepts_payload_at_limit() { + let consensus = MorphConsensus::new(create_test_chainspec()); + // A default legacy tx is well under the 720 KiB cap. + let block = create_sealed_block(0, vec![create_regular_tx()]); + + assert!(consensus.validate_block_pre_execution(&block).is_ok()); + assert!( + l2_tx_payload_bytes(&block.body().transactions) <= MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK + ); + } + + #[test] + fn test_validate_block_pre_execution_rejects_oversized_l2_payload() { + let consensus = MorphConsensus::new(create_test_chainspec()); + let oversized = create_legacy_tx_with_input(Bytes::from(vec![ + 0u8; + MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK + as usize + ])); + let size = l2_tx_payload_bytes(std::slice::from_ref(&oversized)); + assert!(size > MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK); + + let block = create_sealed_block(0, vec![oversized]); + let result = consensus.validate_block_pre_execution(&block); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("invalid block payload size"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_validate_block_pre_execution_ignores_large_l1_messages() { + let consensus = MorphConsensus::new(create_test_chainspec()); + let huge_l1 = create_l1_msg_tx_with_input( + 0, + Bytes::from(vec![ + 1u8; + MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK as usize + 1024 + ]), + ); + let block = create_sealed_block_with_next_l1(0, vec![huge_l1, create_regular_tx()], 1); + + assert!(consensus.validate_block_pre_execution(&block).is_ok()); + } + #[test] fn test_validate_morph_tx_v1_fee_token_0_with_fee_limit_rejected() { use alloy_consensus::Signed; diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs index ed56fe8e..95282e61 100644 --- a/crates/node/src/args.rs +++ b/crates/node/src/args.rs @@ -1,6 +1,7 @@ //! Morph node CLI arguments. use clap::Args; +use morph_chainspec::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK; /// Default maximum L2 transaction payload bytes per block (720 KiB). /// @@ -10,7 +11,7 @@ use clap::Args; /// historical headroom under that usable size; six of them stay under the /// uncompressed 6-blob budget and do not require the submitter to split a /// single L2 block. -pub const MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES: u64 = 720 * 1024; +pub const MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES: u64 = MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK; /// Morph-specific CLI arguments. /// @@ -26,6 +27,10 @@ pub struct MorphArgs { /// /// Default: 737280 bytes (720 KiB), sized so one L2 block fits in a /// 6-blob batch even without compression. + /// + /// Import-time consensus always enforces + /// [`morph_chainspec::MORPH_MAX_TX_PAYLOAD_BYTES_PER_BLOCK`], independent of + /// this flag. Packing above that value produces blocks other nodes reject. #[arg( long = "morph.max-tx-payload-bytes", value_name = "BYTES",