From 8ae2dd40573fc892411c28f2642c164cc0cdbe24 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 14 Aug 2026 12:01:09 +0800 Subject: [PATCH 1/2] feat: ramp sequencer header gasLimit toward --builder.gaslimit Wire reth's --builder.gaslimit / --miner.gaslimit into Morph assemble. When set, each new block moves toward the target by at most ~1/1024 of the parent (Ethereum CalcGasLimit). Explicit payload gas_limit overrides are unchanged so derivation still imports the header as given. Fixes #156 --- README.md | 1 + crates/node/src/components/payload.rs | 15 ++++- crates/payload/builder/src/builder.rs | 4 +- crates/payload/builder/src/config.rs | 87 ++++++++++++++++++++++++++- 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a2239452..3f452f9f 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ openssl rand -hex 32 > jwt.hex | Flag | Default | Description | |------|---------|-------------| +| `--builder.gaslimit` / `--miner.gaslimit` | parent header | Sequencer target for block header `gasLimit` (ramps by at most ~1/1024 per block) | | `--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) | diff --git a/crates/node/src/components/payload.rs b/crates/node/src/components/payload.rs index 2035c676..868fd294 100644 --- a/crates/node/src/components/payload.rs +++ b/crates/node/src/components/payload.rs @@ -5,6 +5,7 @@ use morph_evm::MorphEvmConfig; use morph_payload_builder::{MorphBuilderConfig, MorphPayloadBuilder}; use reth_node_api::FullNodeTypes; use reth_node_builder::{BuilderContext, components::PayloadBuilderBuilder}; +use reth_node_core::cli::config::PayloadBuilderConfig; use reth_tracing::tracing::info; use reth_transaction_pool::blobstore::InMemoryBlobStore; @@ -60,10 +61,20 @@ where pool: morph_txpool::MorphTransactionPool, evm_config: MorphEvmConfig, ) -> eyre::Result { + let mut config = self.config; + let desired_gas_limit = ctx.payload_builder_config().gas_limit(); + if let Some(desired) = desired_gas_limit { + config = config.with_desired_gas_limit(desired); + } + let builder = - MorphPayloadBuilder::with_config(pool, evm_config, ctx.provider().clone(), self.config); + MorphPayloadBuilder::with_config(pool, evm_config, ctx.provider().clone(), config); - info!(target: "morph::node", "Payload builder initialized"); + info!( + target: "morph::node", + ?desired_gas_limit, + "Payload builder initialized" + ); Ok(builder) } diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index e2dc38b4..28d5e859 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -718,7 +718,9 @@ where timestamp: attributes.timestamp, suggested_fee_recipient: attributes.suggested_fee_recipient, prev_randao: attributes.prev_randao, - gas_limit: attributes.gas_limit.unwrap_or(ctx.parent().gas_limit()), + gas_limit: ctx + .builder_config + .next_header_gas_limit(ctx.parent().gas_limit(), attributes.gas_limit), withdrawals: Some(attributes.withdrawals.clone()), parent_beacon_block_root: attributes.parent_beacon_block_root, extra_data: Default::default(), diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 7a618b5b..6a46fd02 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -47,6 +47,15 @@ pub struct MorphBuilderConfig { /// /// This corresponds to the `--morph.max-tx-per-block` CLI flag. pub max_tx_per_block: Option, + + /// Sequencer target for the block header `gasLimit` (GasCeil). + /// + /// When set, and payload attributes do not override `gas_limit`, each assembled + /// block ramps toward this value by at most ~1/1024 of the parent (Ethereum + /// `CalcGasLimit`). When `None`, the header copies the parent `gasLimit`. + /// + /// Seeded from `--builder.gaslimit` / `--miner.gaslimit`. + pub desired_gas_limit: Option, } impl Default for MorphBuilderConfig { @@ -59,6 +68,8 @@ impl Default for MorphBuilderConfig { max_da_block_size: None, // No transaction count limit by default max_tx_per_block: None, + // No header gas target: copy parent gasLimit + desired_gas_limit: None, } } } @@ -70,12 +81,14 @@ impl MorphBuilderConfig { time_limit: Duration, max_da_block_size: Option, max_tx_per_block: Option, + desired_gas_limit: Option, ) -> Self { Self { gas_limit, time_limit, max_da_block_size, max_tx_per_block, + desired_gas_limit, } } @@ -103,6 +116,33 @@ impl MorphBuilderConfig { self } + /// Sets the sequencer header `gasLimit` target. + pub const fn with_desired_gas_limit(mut self, desired_gas_limit: u64) -> Self { + self.desired_gas_limit = Some(desired_gas_limit); + self + } + + /// Header `gasLimit` for the next block. + /// + /// Explicit payload-attribute overrides (safe/derivation import) win. Otherwise the + /// configured GasCeil is applied with Ethereum 1/1024 elasticity, or the parent + /// value is copied when no target is set. + pub fn next_header_gas_limit( + &self, + parent_gas_limit: u64, + attributes_gas_limit: Option, + ) -> u64 { + if let Some(explicit) = attributes_gas_limit { + return explicit; + } + match self.desired_gas_limit { + Some(desired) => { + alloy_eips::eip1559::calculate_block_gas_limit(parent_gas_limit, desired) + } + None => parent_gas_limit, + } + } + /// 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 @@ -235,6 +275,7 @@ mod tests { 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); + assert_eq!(config.desired_gas_limit, None); } #[test] @@ -243,12 +284,14 @@ mod tests { .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_max_tx_per_block(1000) + .with_desired_gas_limit(60_000_000); 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)); + assert_eq!(config.desired_gas_limit, Some(60_000_000)); } #[test] @@ -369,4 +412,46 @@ mod tests { // and 21001 would NOT trigger the breaker // Since it does trigger, we know it's using configured_limit } + + #[test] + fn next_header_gas_limit_copies_parent_without_target() { + let config = MorphBuilderConfig::default(); + assert_eq!(config.next_header_gas_limit(30_000_000, None), 30_000_000); + } + + #[test] + fn next_header_gas_limit_attributes_override_wins() { + let config = MorphBuilderConfig::default().with_desired_gas_limit(60_000_000); + assert_eq!( + config.next_header_gas_limit(30_000_000, Some(30_000_100)), + 30_000_100 + ); + } + + #[test] + fn next_header_gas_limit_ramps_toward_desired_within_1024() { + let parent = 30_000_000u64; + let config = MorphBuilderConfig::default().with_desired_gas_limit(60_000_000); + let next = config.next_header_gas_limit(parent, None); + + let max_delta = parent / 1024; + assert!(next > parent, "should increase toward 60M"); + assert!( + next - parent < max_delta, + "step {step} must be strictly less than parent/1024 ({max_delta})", + step = next - parent + ); + assert_eq!( + next, + alloy_eips::eip1559::calculate_block_gas_limit(parent, 60_000_000) + ); + } + + #[test] + fn next_header_gas_limit_reaches_nearby_desired_in_one_step() { + let parent = 30_000_000u64; + let desired = parent + 100; + let config = MorphBuilderConfig::default().with_desired_gas_limit(desired); + assert_eq!(config.next_header_gas_limit(parent, None), desired); + } } From 8054a70f3b66a0cac975552ed3966c3c54c38199 Mon Sep 17 00:00:00 2001 From: panos Date: Mon, 17 Aug 2026 16:05:45 +0800 Subject: [PATCH 2/2] fix: clamp sequencer gasLimit target to the protocol minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calculate_block_gas_limit` has no `MinGasLimit` floor, unlike geth's `CalcGasLimit` (core/block_validator.go), so a sub-5000 `--builder.gaslimit` ramped the header below the limit header validation accepts, leaving the sequencer unable to produce canonical blocks. Raise the target to the floor before ramping, and move `MINIMUM_GAS_LIMIT` to morph-chainspec so the producer and the validator read one definition instead of hardcoding 5000 on each side. Add the coverage the unit tests were missing. They all called `next_header_gas_limit` directly, so deleting the node-config wiring in components/payload.rs left them green — the part this change is actually about had no guard. `TestNodeBuilder::with_desired_gas_limit` now drives `--builder.gaslimit` through a real node (via `E2ETestSetupBuilder`, since `setup_engine` cannot set it), and the assembled headers are imported through consensus validation, so a header violating the 1/1024 bound or the minimum fails the test. Also cover ramp-down, multi-block convergence, and the sub-minimum target. Drop the unused `MorphBuilderConfig::new`, replace the assertion that restated `calculate_block_gas_limit` with the expected step value, and move the two upstream reth flags out of the Morph-specific README table. --- README.md | 7 +- crates/chainspec/src/constants.rs | 14 ++++ crates/consensus/src/validation.rs | 5 +- crates/node/src/test_utils.rs | 23 +++++- crates/node/tests/it/block_building.rs | 70 +++++++++++++++++ crates/payload/builder/src/config.rs | 105 +++++++++++++++++-------- 6 files changed, 185 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 3f452f9f..257bb550 100644 --- a/README.md +++ b/README.md @@ -112,11 +112,16 @@ openssl rand -hex 32 > jwt.hex | Flag | Default | Description | |------|---------|-------------| -| `--builder.gaslimit` / `--miner.gaslimit` | parent header | Sequencer target for block header `gasLimit` (ramps by at most ~1/1024 per block) | | `--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) | +#### Upstream Reth Flags with Morph-Specific Behavior + +| Flag | Default | Description | +|------|---------|-------------| +| `--builder.gaslimit` / `--miner.gaslimit` | None (copy parent header) | Sequencer target for the block header `gasLimit`. Each assembled block ramps toward it by at most ~1/1024 of the parent, as morph-geth's `--miner.gaslimit` does. Unset leaves the header copying the parent. Ignored when payload attributes carry an explicit `gasLimit` (derivation import). | + ### Running Tests ```bash diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs index 56b98755..489d2c31 100644 --- a/crates/chainspec/src/constants.rs +++ b/crates/chainspec/src/constants.rs @@ -61,6 +61,20 @@ pub const L2_MESSAGE_QUEUE_ADDRESS: Address = address!("530000000000000000000000 /// This is slot 33, which stores the Merkle root for L2->L1 messages. pub const L2_MESSAGE_QUEUE_WITHDRAW_TRIE_ROOT_SLOT: U256 = U256::from_limbs([33, 0, 0, 0]); +// ============================================================================= +// Protocol Gas Constants +// ============================================================================= + +/// Lowest block `gasLimit` the protocol accepts. +/// +/// Matches go-ethereum's `params.MinGasLimit` (`params/protocol_params.go`). +/// Both ends of block production read it, which is why it lives here rather than +/// in either crate alone: the sequencer clamps its `gasLimit` target to it before +/// ramping (`morph-payload-builder`), and header validation rejects anything below +/// it (`morph-consensus`). One definition keeps producer and validator from +/// drifting apart. +pub const MINIMUM_GAS_LIMIT: u64 = 5000; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/consensus/src/validation.rs b/crates/consensus/src/validation.rs index cd12183c..809852e8 100644 --- a/crates/consensus/src/validation.rs +++ b/crates/consensus/src/validation.rs @@ -38,7 +38,7 @@ use crate::MorphConsensusError; use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH, TxReceipt}; use alloy_evm::block::BlockExecutionResult; use alloy_primitives::{B256, Bloom}; -use morph_chainspec::{MorphChainSpec, MorphHardforks}; +use morph_chainspec::{MINIMUM_GAS_LIMIT, MorphChainSpec, MorphHardforks}; use morph_primitives::{ Block, BlockBody, MorphHeader, MorphReceipt, MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1, @@ -63,9 +63,6 @@ const MORPH_MAXIMUM_BASE_FEE: u64 = 10_000_000_000; /// Maximum gas limit (2^63 - 1) const MAX_GAS_LIMIT: u64 = 0x7fffffffffffffff; -/// Minimum gas limit allowed for transactions. -const MINIMUM_GAS_LIMIT: u64 = 5000; - /// The bound divisor of the gas limit, used in update calculations. const GAS_LIMIT_BOUND_DIVISOR: u64 = 1024; diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index d58cfe5e..426de6b0 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -190,6 +190,7 @@ pub struct TestNodeBuilder { schedule: HardforkSchedule, num_nodes: usize, is_dev: bool, + desired_gas_limit: Option, } impl Default for TestNodeBuilder { @@ -213,6 +214,7 @@ impl TestNodeBuilder { schedule: HardforkSchedule::AllActive, num_nodes: 1, is_dev: false, + desired_gas_limit: None, } } @@ -260,6 +262,14 @@ impl TestNodeBuilder { self } + /// Set the sequencer's header `gasLimit` target, as `--builder.gaslimit` would. + /// + /// Assembled blocks then ramp toward this value instead of copying the parent. + pub fn with_desired_gas_limit(mut self, desired_gas_limit: u64) -> Self { + self.desired_gas_limit = Some(desired_gas_limit); + self + } + /// Build and launch the configured nodes. /// /// Returns the node handles and a wallet derived from @@ -271,13 +281,20 @@ impl TestNodeBuilder { let genesis: Genesis = serde_json::from_value(self.genesis_json)?; let chain_spec = morph_chainspec::MorphChainSpec::from_genesis(genesis); - reth_e2e_test_utils::setup_engine( + // Built via `E2ETestSetupBuilder` rather than `setup_engine` so the node config + // can carry `--builder.gaslimit`, which `setup_engine` gives no way to set. + let is_dev = self.is_dev; + let desired_gas_limit = self.desired_gas_limit; + reth_e2e_test_utils::E2ETestSetupBuilder::::new( self.num_nodes, Arc::new(chain_spec), - self.is_dev, - Default::default(), morph_payload_attributes, ) + .with_node_config_modifier(move |mut config| { + config.builder.gas_limit = desired_gas_limit; + config.set_dev(is_dev) + }) + .build() .await } } diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs index 739fa14a..4cae8f5a 100644 --- a/crates/node/tests/it/block_building.rs +++ b/crates/node/tests/it/block_building.rs @@ -148,6 +148,76 @@ async fn l1_messages_precede_l2_transactions() -> eyre::Result<()> { Ok(()) } +/// Genesis `gasLimit` in `tests/assets/test-genesis.json`, matching mainnet. +const GENESIS_GAS_LIMIT: u64 = 30_000_000; + +/// With `--builder.gaslimit` set, each assembled header must step toward the target +/// by go-ethereum's `CalcGasLimit` delta, and every such block must still import. +/// +/// This covers the wiring, not just the arithmetic: the target has to travel from the +/// node config through `MorphBuilderConfig` into the header, and `advance_empty_block` +/// runs `submit_payload` + `update_forkchoice`, so a header the consensus rules reject +/// fails the test rather than passing silently. +#[tokio::test(flavor = "multi_thread")] +async fn header_gas_limit_ramps_toward_builder_target() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let target = 60_000_000u64; + let (mut nodes, _wallet) = TestNodeBuilder::new() + .with_desired_gas_limit(target) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let mut parent_gas_limit = GENESIS_GAS_LIMIT; + for block_number in 1..=3u64 { + let payload = advance_empty_block(&mut node).await?; + let header = payload.block().header(); + assert_eq!(header.inner.number, block_number); + + // Derived from geth's rule rather than from the implementation: the step is + // `parent/1024 - 1`, one below the diff header validation starts rejecting. + let expected = parent_gas_limit + parent_gas_limit / 1024 - 1; + assert_eq!( + header.inner.gas_limit, expected, + "block {block_number} should ramp from {parent_gas_limit} to {expected}" + ); + assert!( + header.inner.gas_limit < target, + "ramp must not overshoot the target in a single block" + ); + + parent_gas_limit = header.inner.gas_limit; + } + + // First step from mainnet's 30M, pinned so a changed bound divisor is caught. + assert_eq!(GENESIS_GAS_LIMIT + GENESIS_GAS_LIMIT / 1024 - 1, 30_029_295); + + Ok(()) +} + +/// Without `--builder.gaslimit`, headers keep copying the parent `gasLimit`. This is +/// the default every deployment runs, so it gets its own guard. +#[tokio::test(flavor = "multi_thread")] +async fn header_gas_limit_copies_parent_without_builder_target() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + for block_number in 1..=3u64 { + let payload = advance_empty_block(&mut node).await?; + let header = payload.block().header(); + assert_eq!(header.inner.number, block_number); + assert_eq!( + header.inner.gas_limit, GENESIS_GAS_LIMIT, + "gasLimit must not drift when no target is configured" + ); + } + + Ok(()) +} + /// Multiple L1 messages with strictly sequential queue indices in one block. #[tokio::test(flavor = "multi_thread")] async fn multiple_l1_messages_sequential_queue_indices() -> eyre::Result<()> { diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 6a46fd02..2f98f563 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -1,6 +1,8 @@ //! Configuration for the Morph payload builder. +use alloy_eips::eip1559::calculate_block_gas_limit; use core::time::Duration; +use morph_chainspec::MINIMUM_GAS_LIMIT; use reth_chainspec::MIN_TRANSACTION_GAS; use reth_primitives_traits::FastInstant as Instant; use std::fmt::Debug; @@ -75,23 +77,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, - max_da_block_size: Option, - max_tx_per_block: Option, - desired_gas_limit: Option, - ) -> Self { - Self { - gas_limit, - time_limit, - max_da_block_size, - max_tx_per_block, - desired_gas_limit, - } - } - /// Sets the gas limit. pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { self.gas_limit = Some(gas_limit); @@ -136,8 +121,13 @@ impl MorphBuilderConfig { return explicit; } match self.desired_gas_limit { + // A sub-minimum target is raised to `MINIMUM_GAS_LIMIT` before ramping, + // mirroring go-ethereum's `CalcGasLimit` (`core/block_validator.go`). + // `calculate_block_gas_limit` has no such floor on its own, so without + // this the sequencer would ramp the header below the limit its own + // header validation accepts and stop producing canonical blocks. Some(desired) => { - alloy_eips::eip1559::calculate_block_gas_limit(parent_gas_limit, desired) + calculate_block_gas_limit(parent_gas_limit, desired.max(MINIMUM_GAS_LIMIT)) } None => parent_gas_limit, } @@ -280,6 +270,8 @@ mod tests { #[test] fn test_config_builder_pattern() { + // `gas_limit` is the soft packing cap, `desired_gas_limit` is the header + // ceiling the sequencer ramps toward. Different dimensions, both set here. let config = MorphBuilderConfig::default() .with_gas_limit(20_000_000) .with_time_limit(Duration::from_millis(500)) @@ -413,40 +405,91 @@ mod tests { // Since it does trigger, we know it's using configured_limit } + /// Mainnet's genesis `gasLimit`, used as the parent value throughout these tests. + const MAINNET_GAS_LIMIT: u64 = 30_000_000; + #[test] fn next_header_gas_limit_copies_parent_without_target() { let config = MorphBuilderConfig::default(); - assert_eq!(config.next_header_gas_limit(30_000_000, None), 30_000_000); + assert_eq!( + config.next_header_gas_limit(MAINNET_GAS_LIMIT, None), + MAINNET_GAS_LIMIT + ); } #[test] fn next_header_gas_limit_attributes_override_wins() { + // Derivation imports (`newSafeL2Block`) carry the header's own value, which must + // survive untouched even when the sequencer has a different target configured. let config = MorphBuilderConfig::default().with_desired_gas_limit(60_000_000); assert_eq!( - config.next_header_gas_limit(30_000_000, Some(30_000_100)), + config.next_header_gas_limit(MAINNET_GAS_LIMIT, Some(30_000_100)), 30_000_100 ); } #[test] fn next_header_gas_limit_ramps_toward_desired_within_1024() { - let parent = 30_000_000u64; + let parent = MAINNET_GAS_LIMIT; let config = MorphBuilderConfig::default().with_desired_gas_limit(60_000_000); - let next = config.next_header_gas_limit(parent, None); - - let max_delta = parent / 1024; - assert!(next > parent, "should increase toward 60M"); - assert!( - next - parent < max_delta, - "step {step} must be strictly less than parent/1024 ({max_delta})", - step = next - parent + + // parent/1024 - 1 == 29_295, the largest step header validation accepts: + // `validate_against_parent_gas_limit` rejects a diff of parent/1024 or more. + assert_eq!(config.next_header_gas_limit(parent, None), 30_029_295); + assert_eq!( + config.next_header_gas_limit(parent, None) - parent, + parent / 1024 - 1 ); + } + + #[test] + fn next_header_gas_limit_ramps_down_toward_lower_desired() { + let parent = MAINNET_GAS_LIMIT; + let config = MorphBuilderConfig::default().with_desired_gas_limit(20_000_000); + + assert_eq!(config.next_header_gas_limit(parent, None), 29_970_705); assert_eq!( - next, - alloy_eips::eip1559::calculate_block_gas_limit(parent, 60_000_000) + parent - config.next_header_gas_limit(parent, None), + parent / 1024 - 1 ); } + #[test] + fn next_header_gas_limit_converges_on_desired_and_stays() { + for desired in [60_000_000u64, 10_000_000] { + let config = MorphBuilderConfig::default().with_desired_gas_limit(desired); + let mut gas_limit = MAINNET_GAS_LIMIT; + + // Ramping is bounded per block, so convergence takes hundreds of blocks. + for _ in 0..4000 { + gas_limit = config.next_header_gas_limit(gas_limit, None); + } + + assert_eq!(gas_limit, desired, "should converge on {desired}"); + // Once reached, the target is a fixed point rather than oscillating. + assert_eq!(config.next_header_gas_limit(gas_limit, None), desired); + } + } + + #[test] + fn next_header_gas_limit_never_ramps_below_protocol_minimum() { + // A target below `MINIMUM_GAS_LIMIT` (0 being the likeliest bad input) must be + // raised to the floor, as go-ethereum's `CalcGasLimit` does. Ramping past it + // would build headers the node's own validation rejects. + let config = MorphBuilderConfig::default().with_desired_gas_limit(0); + let mut gas_limit = MAINNET_GAS_LIMIT; + + for _ in 0..20_000 { + gas_limit = config.next_header_gas_limit(gas_limit, None); + assert!( + gas_limit >= MINIMUM_GAS_LIMIT, + "ramped to {gas_limit}, below the {MINIMUM_GAS_LIMIT} floor" + ); + } + + assert_eq!(gas_limit, MINIMUM_GAS_LIMIT); + } + #[test] fn next_header_gas_limit_reaches_nearby_desired_in_one_step() { let parent = 30_000_000u64;