From 8e9728a51efe59a7315d1b5bf115aeed3038e9bc Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 24 Sep 2026 15:44:58 +0800 Subject: [PATCH 1/2] fix(payload): skip execution artifacts for empty blocks reth inserts every resolved payload into the engine tree as an already-executed block and only prunes such non-canonical blocks below a finalized block. The sequencer CL assembles a candidate on every fast tick (300ms), discards it when it carries no transactions without telling the EL, and never sends a finalized tag, so every discarded empty candidate stayed in memory for the life of the process (about 7.6KB each, roughly 0.5-1 per second while the chain is idle). Build empty blocks without the executed-block artifacts so they are never pre-inserted. The empty blocks the CL does commit are executed on import instead, which is cheap for a block without transactions and also means the sequencer validates them the way every follower does. Blocks with transactions keep the artifacts and the already-seen import path. Claude-Session: https://claude.ai/code/session_013oN3EyKzA7m5ykYdT5g6WL --- crates/node/tests/it/block_building.rs | 42 +++++++ crates/node/tests/it/engine.rs | 147 ++++++++++++++++++++++++- crates/payload/builder/src/builder.rs | 36 +++--- crates/payload/types/src/built.rs | 7 +- 4 files changed, 213 insertions(+), 19 deletions(-) diff --git a/crates/node/tests/it/block_building.rs b/crates/node/tests/it/block_building.rs index e5b2fde8..2b11f2dd 100644 --- a/crates/node/tests/it/block_building.rs +++ b/crates/node/tests/it/block_building.rs @@ -33,6 +33,48 @@ async fn empty_block_has_no_transactions() -> eyre::Result<()> { Ok(()) } +/// A block without transactions carries no execution artifacts; a block with +/// transactions still does. +/// +/// reth pre-inserts every resolved payload into the engine tree through these +/// artifacts and only prunes such non-canonical blocks below a finalized block. The +/// sequencer's CL assembles a candidate on every fast tick, discards the empty ones +/// without telling the EL, and never supplies a finalized tag, so pre-inserted empty +/// candidates would accumulate for the life of the process. +#[tokio::test(flavor = "multi_thread")] +async fn empty_block_carries_no_executed_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let mut node = nodes.pop().unwrap(); + + // The transfer block goes first: `advance_chain` consumes its own payload events, + // while `advance_empty_block` leaves unread events behind on the shared stream. + let wallet = wallet_to_arc(wallet); + let payloads = advance_chain(1, &mut node, wallet).await?; + assert_eq!( + payloads[0].block().body().transactions.len(), + 1, + "precondition: the block must carry the transfer" + ); + assert!( + payloads[0].executed().is_some(), + "a block with transactions keeps its execution artifacts" + ); + + let empty = advance_empty_block(&mut node).await?; + assert!( + empty.block().body().transactions.is_empty(), + "precondition: the block must be empty" + ); + assert!( + empty.executed().is_none(), + "an empty block must not carry execution artifacts" + ); + + Ok(()) +} + /// A block containing a single EIP-1559 transfer transaction. #[tokio::test(flavor = "multi_thread")] async fn block_with_single_transfer() -> eyre::Result<()> { diff --git a/crates/node/tests/it/engine.rs b/crates/node/tests/it/engine.rs index 4f749c53..8b202db6 100644 --- a/crates/node/tests/it/engine.rs +++ b/crates/node/tests/it/engine.rs @@ -12,10 +12,11 @@ use alloy_rpc_types_engine::PayloadAttributes; use jsonrpsee::core::client::ClientT; use morph_node::test_utils::{ HardforkSchedule, L1MessageBuilder, MorphTxBuilder, TEST_TOKEN_ID, TestNodeBuilder, + make_transfer_tx, }; use morph_payload_types::{ - AssembleL2BlockParams, ExecutableL2Data, GenericResponse, MorphPayloadAttributes, - MorphPayloadTypes, SafeL2Data, + AssembleL2BlockParams, AssembleL2BlockV2Params, ExecutableL2Data, GenericResponse, + MorphPayloadAttributes, MorphPayloadTypes, SafeL2Data, }; use morph_primitives::MorphHeader; use reth_node_api::PayloadTypes; @@ -24,9 +25,9 @@ use reth_payload_primitives::BuiltPayload; use reth_provider::{BlockIdReader, BlockReaderIdExt}; use super::helpers::{ - assemble_l2_block, build_block_no_submit, canonical_block, canonical_snapshot, - craft_and_try_import_block, head_timestamp, import_l2_block, transaction_hashes, - wait_until_pooled, + LOCAL_POLL_BUDGET, POLL_INTERVAL, assemble_l2_block, assemble_l2_block_v2, + build_block_no_submit, canonical_block, canonical_snapshot, craft_and_try_import_block, + head_timestamp, import_l2_block, transaction_hashes, wait_until_pooled, }; /// Pre-Jade: a block with a wrong state root is still accepted. @@ -1038,3 +1039,139 @@ async fn new_safe_l2_block_rejects_transactions_over_gas_limit() -> eyre::Result Ok(()) } + +/// An assembled empty candidate leaves nothing behind in the engine tree. +/// +/// The CL's fast tick assembles a candidate on the head and discards it when it carries +/// no transactions, without telling the EL. reth pre-inserts resolved payloads into the +/// engine tree (surfacing them as the pending block) and only prunes non-canonical tree +/// blocks below a finalized block, which a sequencer never receives. Empty candidates are +/// therefore not pre-inserted at all, while a candidate with a transaction still is, so +/// that its import stays a no-op. +#[tokio::test(flavor = "multi_thread")] +async fn empty_candidate_is_not_pre_inserted_into_engine_tree() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let head = canonical_snapshot(&node)?; + let timestamp = head_timestamp(&node)? + 1; + + // The discarded-candidate shape: assemble on the head and never import. + let empty = assemble_l2_block_v2( + &node, + AssembleL2BlockV2Params { + parent_hash: head.hash, + transactions: vec![], + timestamp: Some(timestamp), + }, + ) + .await?; + assert!( + empty.transactions.is_empty(), + "precondition: the candidate must be empty" + ); + + // The built-payload event reaches the tree asynchronously; give it ample time. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert_eq!( + node.inner.provider.pending_block_num_hash()?, + None, + "an empty candidate must not be pre-inserted as the pending block" + ); + + // A candidate with a transaction is still pre-inserted. + let raw_tx = make_transfer_tx(wallet.chain_id, wallet.inner.clone(), 0).await; + let tx_hash = node.rpc.inject_tx(raw_tx).await?; + wait_until_pooled(&node, tx_hash).await?; + let candidate = assemble_l2_block_v2( + &node, + AssembleL2BlockV2Params { + parent_hash: head.hash, + transactions: vec![], + timestamp: Some(timestamp), + }, + ) + .await?; + assert_eq!( + candidate.transactions.len(), + 1, + "precondition: the candidate must carry the transfer" + ); + + let deadline = tokio::time::Instant::now() + LOCAL_POLL_BUDGET; + loop { + let pending = node.inner.provider.pending_block_num_hash()?; + if pending.map(|p| p.hash) == Some(candidate.hash) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "a candidate with a transaction should be pre-inserted as the pending block, got {pending:?}" + ); + tokio::time::sleep(POLL_INTERVAL).await; + } + + let header = import_l2_block(&node, candidate.clone()).await?; + assert_eq!(header.hash_slow(), candidate.hash); + assert_eq!( + canonical_snapshot(&node)?.hash, + candidate.hash, + "the pre-inserted candidate should become the canonical head" + ); + + Ok(()) +} + +/// A committed empty block is validated on import instead of taking the already-seen +/// shortcut. +/// +/// With no execution artifacts to pre-insert, `engine_newL2BlockV2` executes and +/// validates an empty block the way every follower does. An empty block whose timestamp +/// precedes its parent is therefore rejected by the sequencer itself instead of being +/// written to its chain and rejected everywhere else. +#[tokio::test(flavor = "multi_thread")] +async fn empty_block_with_past_timestamp_is_rejected_on_import() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _wallet) = TestNodeBuilder::new().build().await?; + let node = nodes.pop().unwrap(); + + let mut params = AssembleL2BlockParams::empty(1); + params.timestamp = Some(10); + let block1 = assemble_l2_block(&node, params).await?; + import_l2_block(&node, block1.clone()).await?; + let before = canonical_snapshot(&node)?; + assert_eq!(before.hash, block1.hash, "block 1 should be the head"); + + // The builder does not clamp a caller-supplied timestamp, so this candidate is built. + let stale = assemble_l2_block_v2( + &node, + AssembleL2BlockV2Params { + parent_hash: block1.hash, + transactions: vec![], + timestamp: Some(5), + }, + ) + .await?; + assert_eq!( + stale.timestamp, 5, + "precondition: the candidate keeps the stale timestamp" + ); + + let err = import_l2_block(&node, stale) + .await + .expect_err("an empty block with a timestamp before its parent must be rejected"); + assert!( + err.to_string().contains("timestamp"), + "rejection should name the timestamp rule, got: {err}" + ); + assert_eq!( + canonical_snapshot(&node)?, + before, + "a rejected block must leave the canonical chain untouched" + ); + + Ok(()) +} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index b3da4b7f..2848c888 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -943,6 +943,7 @@ where // Build ExecutableL2Data from the sealed block // ExecutableL2Data expects raw 256-byte bloom, not RLP-encoded bytes. let logs_bloom_bytes = header.logs_bloom().as_slice().to_vec(); + let has_transactions = !executed_txs.is_empty(); let executable_data = ExecutableL2Data { parent_hash: header.parent_hash(), @@ -961,25 +962,34 @@ where hash: sealed_block.hash(), }; - let execution_output = BlockExecutionOutput { - result: execution_result, - state: db.take_bundle(), - }; - - let executed = BuiltPayloadExecutedBlock { - recovered_block: Arc::new(block), - execution_output: Arc::new(execution_output), - // Keep unsorted; conversion to sorted is deferred until required. - hashed_state: Arc::new(hashed_state), - trie_updates: Arc::new(trie_updates), - }; + // reth inserts every resolved payload into the engine tree as an already-executed + // block, and the tree only prunes such non-canonical blocks below a finalized block. + // A sequencer assembles a candidate on every fast tick, discards the empty ones + // without telling the EL, and never receives a finalized tag, so pre-inserted empty + // candidates would stay in memory for the life of the process. Empty blocks + // therefore carry no execution artifacts: the ones the CL does commit are executed + // again on import, which costs nothing for a block without transactions. Blocks + // with transactions keep the artifacts so their import remains a no-op. + let executed = has_transactions.then(|| { + let execution_output = BlockExecutionOutput { + result: execution_result, + state: db.take_bundle(), + }; + BuiltPayloadExecutedBlock { + recovered_block: Arc::new(block), + execution_output: Arc::new(execution_output), + // Keep unsorted; conversion to sorted is deferred until required. + hashed_state: Arc::new(hashed_state), + trie_updates: Arc::new(trie_updates), + } + }); let payload = MorphBuiltPayload::new( ctx.payload_id(), sealed_block, info.total_fees, executable_data, - Some(executed), + executed, ); // Only record block_transactions for successfully built payloads (not Aborted or Cancelled). diff --git a/crates/payload/types/src/built.rs b/crates/payload/types/src/built.rs index 3a9bd506..2f45b1a2 100644 --- a/crates/payload/types/src/built.rs +++ b/crates/payload/types/src/built.rs @@ -29,6 +29,11 @@ pub struct MorphBuiltPayload { pub executable_data: ExecutableL2Data, /// Full execution artifacts for reth-native block persistence. + /// + /// `None` for blocks without transactions. reth pre-inserts every resolved payload + /// into the engine tree through these artifacts, and a sequencer discards most of + /// its empty candidates without ever supplying the finalized tag that would prune + /// them again, so empty blocks are left out and executed on import instead. pub executed: Option>, } @@ -70,7 +75,7 @@ impl MorphBuiltPayload { &self.executable_data } - /// Returns execution artifacts if available. + /// Returns execution artifacts if available (`None` for blocks without transactions). pub fn executed(&self) -> Option<&BuiltPayloadExecutedBlock> { self.executed.as_ref() } From 24cabf5dcc72033932dcfa4046ade8e2af7691b1 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 24 Sep 2026 23:39:10 +0800 Subject: [PATCH 2/2] test(engine): keep empty-block import coverage compatible with timestamp clamp --- crates/node/tests/it/engine.rs | 44 +++++++++++++--------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/crates/node/tests/it/engine.rs b/crates/node/tests/it/engine.rs index 8b202db6..28c2691e 100644 --- a/crates/node/tests/it/engine.rs +++ b/crates/node/tests/it/engine.rs @@ -1124,15 +1124,10 @@ async fn empty_candidate_is_not_pre_inserted_into_engine_tree() -> eyre::Result< Ok(()) } -/// A committed empty block is validated on import instead of taking the already-seen -/// shortcut. -/// -/// With no execution artifacts to pre-insert, `engine_newL2BlockV2` executes and -/// validates an empty block the way every follower does. An empty block whose timestamp -/// precedes its parent is therefore rejected by the sequencer itself instead of being -/// written to its chain and rejected everywhere else. +/// A committed empty block is executed on import after being omitted from the +/// tree's pre-inserted payloads. #[tokio::test(flavor = "multi_thread")] -async fn empty_block_with_past_timestamp_is_rejected_on_import() -> eyre::Result<()> { +async fn empty_block_is_imported_without_execution_artifacts() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut nodes, _wallet) = TestNodeBuilder::new().build().await?; @@ -1142,35 +1137,28 @@ async fn empty_block_with_past_timestamp_is_rejected_on_import() -> eyre::Result params.timestamp = Some(10); let block1 = assemble_l2_block(&node, params).await?; import_l2_block(&node, block1.clone()).await?; - let before = canonical_snapshot(&node)?; - assert_eq!(before.hash, block1.hash, "block 1 should be the head"); - - // The builder does not clamp a caller-supplied timestamp, so this candidate is built. - let stale = assemble_l2_block_v2( + let empty = assemble_l2_block_v2( &node, AssembleL2BlockV2Params { parent_hash: block1.hash, transactions: vec![], - timestamp: Some(5), + timestamp: Some(11), }, ) .await?; - assert_eq!( - stale.timestamp, 5, - "precondition: the candidate keeps the stale timestamp" - ); + assert!(empty.transactions.is_empty()); - let err = import_l2_block(&node, stale) - .await - .expect_err("an empty block with a timestamp before its parent must be rejected"); - assert!( - err.to_string().contains("timestamp"), - "rejection should name the timestamp rule, got: {err}" - ); + // The built-payload event is asynchronous. An empty payload must remain absent + // from the pending tree before import, then take the normal import path. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert_eq!(node.inner.provider.pending_block_num_hash()?, None); + + let imported = import_l2_block(&node, empty.clone()).await?; + assert_eq!(imported.hash_slow(), empty.hash); assert_eq!( - canonical_snapshot(&node)?, - before, - "a rejected block must leave the canonical chain untouched" + canonical_snapshot(&node)?.hash, + empty.hash, + "the empty block must become canonical through normal import" ); Ok(())