From 49255f4d7f7e06ddb66fba0603b377cc58e30bf7 Mon Sep 17 00:00:00 2001 From: panos Date: Fri, 14 Aug 2026 11:57:24 +0800 Subject: [PATCH] fix: seal assemble when an L1 message exceeds remaining gas Match morph-geth: include L1 messages that fit, stop packing the rest, and still return a block. A single message larger than the whole block gas limit is left for a later height instead of failing assemble. Fixes #158 --- crates/node/tests/it/l1_messages.rs | 83 +++++++++++++++++++++++++++ crates/payload/builder/src/builder.rs | 49 +++++++++------- crates/payload/builder/src/error.rs | 11 ---- crates/payload/builder/src/lib.rs | 2 + 4 files changed, 113 insertions(+), 32 deletions(-) diff --git a/crates/node/tests/it/l1_messages.rs b/crates/node/tests/it/l1_messages.rs index 0be68e34..fd9514c7 100644 --- a/crates/node/tests/it/l1_messages.rs +++ b/crates/node/tests/it/l1_messages.rs @@ -161,3 +161,86 @@ async fn l1_message_gas_is_tracked() -> eyre::Result<()> { Ok(()) } + +/// When a later L1 message does not fit remaining block gas, assemble still +/// succeeds with the messages that already fit. The leftover is retried on +/// the next block via `next_l1_msg_index`. +#[tokio::test(flavor = "multi_thread")] +async fn l1_message_gas_overflow_seals_what_fits() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let block_gas_limit = 50_000u64; + let (mut nodes, _wallet) = TestNodeBuilder::new() + .with_gas_limit(block_gas_limit) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let msg0 = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0x01)) + .with_gas_limit(21_000) + .build_encoded(); + let msg1 = L1MessageBuilder::new(1) + .with_target(Address::with_last_byte(0x02)) + .with_gas_limit(40_000) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![msg0, msg1]).await?; + let block = payload.block(); + + assert_eq!( + block.body().transactions.len(), + 1, + "only the L1 message that fits remaining gas should be included" + ); + let tx = block.body().transactions.first().unwrap(); + assert!(tx.is_l1_msg()); + assert_eq!(tx.queue_index(), Some(0)); + assert_eq!(block.header().next_l1_msg_index, 1); + + let leftover = L1MessageBuilder::new(1) + .with_target(Address::with_last_byte(0x02)) + .with_gas_limit(40_000) + .build_encoded(); + let payload2 = advance_block_with_l1_messages(&mut node, vec![leftover]).await?; + let block2 = payload2.block(); + + assert_eq!(block2.body().transactions.len(), 1); + assert_eq!( + block2.body().transactions.first().unwrap().queue_index(), + Some(1) + ); + assert_eq!(block2.header().next_l1_msg_index, 2); + + Ok(()) +} + +/// A single L1 message larger than the whole block gas limit must not abort +/// assemble. The message is left for a later block (`next_l1_msg_index` unchanged). +#[tokio::test(flavor = "multi_thread")] +async fn single_oversized_l1_message_does_not_abort_assemble() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let block_gas_limit = 50_000u64; + let (mut nodes, _wallet) = TestNodeBuilder::new() + .with_gas_limit(block_gas_limit) + .build() + .await?; + let mut node = nodes.pop().unwrap(); + + let oversized = L1MessageBuilder::new(0) + .with_target(Address::with_last_byte(0x01)) + .with_gas_limit(100_000) + .build_encoded(); + + let payload = advance_block_with_l1_messages(&mut node, vec![oversized]).await?; + let block = payload.block(); + + assert!( + block.body().transactions.is_empty(), + "oversized L1 message must not be included" + ); + assert_eq!(block.header().next_l1_msg_index, 0); + + Ok(()) +} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index e2dc38b4..b6621238 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -327,11 +327,16 @@ impl MorphPayloadBuilderCtx { BestTransactionsAttributes::new(base_fee, None) } - /// Executes all L1 message transactions from payload attributes. + /// Executes L1 message transactions from payload attributes. /// /// L1 messages are forced transactions from the L1 bridge that must be executed first. /// They must have sequential queue indices and are never pulled from the transaction pool. /// + /// If the next L1 message does not fit in remaining block gas, packing stops and the + /// leftover messages are left for the next block via `next_l1_msg_index`. A single + /// message larger than the whole block gas limit is skipped for this height (not + /// included, index not advanced) so assemble still returns a block. + /// /// Returns the executed transaction bytes for inclusion in ExecutableL2Data. fn execute_l1_messages( &self, @@ -342,8 +347,6 @@ impl MorphPayloadBuilderCtx { let base_fee = builder.evm().block().basefee(); let l1_tx_count = self.attributes().transactions.len(); let mut executed_txs: Vec = Vec::with_capacity(l1_tx_count); - // Track gas spent by each transaction for error reporting - let mut gas_spent_by_transactions: Vec = Vec::with_capacity(l1_tx_count); for (tx_idx, tx_with_encoded) in self.attributes().transactions.iter().enumerate() { // The transaction is already recovered in `try_new` via `try_into_recovered()`. @@ -361,23 +364,28 @@ 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 { - tracing::warn!( - target: "payload_builder", - tx_index = tx_idx, - tx_gas, - cumulative_gas_used = info.cumulative_gas_used, - block_gas_limit, - "L1 message transaction would exceed block gas limit; aborting build" - ); - gas_spent_by_transactions.push(tx_gas); - return Err(PayloadBuilderError::other( - MorphPayloadBuilderError::BlockGasLimitExceededBySequencerTransactions { - gas_spent_by_tx: gas_spent_by_transactions, - gas: block_gas_limit, - }, - )); + // Match morph-geth: stop L1 packing when the next message does not fit + // remaining gas, and still seal the block with what already fits. + if info.cumulative_gas_used.saturating_add(tx_gas) > block_gas_limit { + if info.transaction_count == 0 { + tracing::warn!( + target: "payload_builder", + tx_index = tx_idx, + tx_gas, + block_gas_limit, + "Single L1 message gas limit exceeded for current block" + ); + } else { + tracing::debug!( + target: "payload_builder", + tx_index = tx_idx, + tx_gas, + cumulative_gas_used = info.cumulative_gas_used, + block_gas_limit, + "L1 message would exceed remaining block gas; stopping L1 packing" + ); + } + break; } // Execute the transaction and record EVM execution time. @@ -463,7 +471,6 @@ impl MorphPayloadBuilderCtx { }; info.cumulative_gas_used += gas_used; - gas_spent_by_transactions.push(gas_used); // Increment transaction count info.transaction_count += 1; diff --git a/crates/payload/builder/src/error.rs b/crates/payload/builder/src/error.rs index 70f7cc99..76a01d8b 100644 --- a/crates/payload/builder/src/error.rs +++ b/crates/payload/builder/src/error.rs @@ -11,17 +11,6 @@ pub enum MorphPayloadBuilderError { #[error("failed to recover transaction signer")] TransactionEcRecoverFailed, - /// Block gas limit exceeded by sequencer transactions. - #[error( - "block gas limit {gas} exceeded by sequencer transactions, gas spent by tx: {gas_spent_by_tx:?}" - )] - BlockGasLimitExceededBySequencerTransactions { - /// Gas spent by each transaction. - gas_spent_by_tx: Vec, - /// Block gas limit. - gas: u64, - }, - /// Invalid sequencer transaction in forced transaction list. #[error("invalid sequencer transaction: {error}")] InvalidSequencerTransaction { diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index 0f9ebf13..2c2ed35a 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -22,6 +22,8 @@ //! - Queue indices must be strictly sequential //! - Gas is prepaid on L1, so no refunds for unused gas //! - L1 messages are never in the transaction pool +//! - If the next L1 message does not fit remaining block gas, packing stops and +//! leftovers are retried on the next block via `next_l1_msg_index` #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]