From 2ad8b2428aa14ab3fa66df8011c920a997f1c757 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 24 Sep 2026 16:22:24 +0800 Subject: [PATCH 1/3] fix(cli): accept peer transactions before the first block after startup reth marks the network as syncing on every start and ignores transaction gossip until the engine commits its first canonical block. That includes the pool announcement a peer sends once when a session opens; the peer records those hashes as known and never announces them again. Morph blocks only ever arrive from the consensus client, so there is nothing to catch up on over p2p and the window is just the gap between start-up and the first imported block. On a sequencer it swallows every transaction RPC nodes were holding while it restarted: those never reach it, and each affected sender's later transactions wait behind the missing nonce. morph-geth accepts transactions from process start. Enable reth's --debug.startup-sync-state-idle for `morph-reth node` by default. The network is marked idle once the engine starts (after an interrupted pipeline backfill, if one is needed), so gossip and session announcements are accepted from start-up. eth_syncing now reports false from start-up as well; it already did after the first imported block however far behind the node was, so it was never a caught-up signal here. --- bin/morph-reth/src/main.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/bin/morph-reth/src/main.rs b/bin/morph-reth/src/main.rs index 65b7a02..fb00fff 100644 --- a/bin/morph-reth/src/main.rs +++ b/bin/morph-reth/src/main.rs @@ -47,6 +47,12 @@ fn apply_morph_cli_defaults( .gas_price_oracle .default_suggested_fee .get_or_insert_with(morph_default_suggested_fee); + // reth ignores transactions from peers until its first canonical block after startup, + // including the one-off pool announcement each peer sends when a session opens, and + // peers do not repeat it. Blocks only ever arrive from the consensus client, so there is + // nothing to catch up on over p2p, and a restarted sequencer would otherwise never see + // the transactions RPC nodes held while it was down. + command.debug.startup_sync_state_idle = true; } } @@ -198,4 +204,15 @@ mod tests { assert!(!command.ext.proofs_history); assert_eq!(command.rpc.rpc_eth_proof_window, 0); } + + #[test] + fn node_command_accepts_peer_transactions_from_startup() { + let mut cli = MorphCli::try_parse_from(["morph-reth", "node", "--chain", "hoodi"]) + .expect("node command must parse"); + apply_morph_cli_defaults(&mut cli); + let Commands::Node(command) = cli.command else { + panic!("expected node command") + }; + assert!(command.debug.startup_sync_state_idle); + } } From 8e9762aac7c72b3a96f237ace390dc37f69b697a Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 24 Sep 2026 16:56:28 +0800 Subject: [PATCH 2/3] fix(node): mark the initial sync done as soon as the network starts The previous commit enabled reth's --debug.startup-sync-state-idle, which switches the network to idle only when the consensus engine task starts. The launcher starts the network, and with it the dials to trusted peers, before it sets the Syncing state, so a session that opens between the two still has its pool announcement dropped. A node restarting against a peer that holds a pending transaction received it in 5 of 20 restarts. reth gates transaction gossip on is_initially_syncing, which stays false for good after the first Syncing -> Idle switch. Build the network through a MorphNetworkBuilder that makes that switch as soon as the network is up, and drop the CLI default. The same restarts now deliver the transaction every time, and is_syncing, and with it eth_syncing, behaves exactly as before. --- Cargo.lock | 1 + Cargo.toml | 1 + bin/morph-reth/src/main.rs | 17 ---------- crates/node/Cargo.toml | 1 + crates/node/src/components/mod.rs | 3 ++ crates/node/src/components/network.rs | 46 +++++++++++++++++++++++++++ crates/node/src/node.rs | 11 ++++--- crates/node/tests/it/main.rs | 1 + crates/node/tests/it/network.rs | 46 +++++++++++++++++++++++++++ 9 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 crates/node/src/components/network.rs create mode 100644 crates/node/tests/it/network.rs diff --git a/Cargo.lock b/Cargo.lock index 94036ec..bca639c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5205,6 +5205,7 @@ dependencies = [ "reth-errors", "reth-evm", "reth-execution-cache", + "reth-network", "reth-node-api", "reth-node-builder", "reth-node-core", diff --git a/Cargo.toml b/Cargo.toml index d39af98..b4311c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,7 @@ reth-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-execution-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } +reth-network = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-network-peers = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } reth-node-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.5.2" } diff --git a/bin/morph-reth/src/main.rs b/bin/morph-reth/src/main.rs index fb00fff..65b7a02 100644 --- a/bin/morph-reth/src/main.rs +++ b/bin/morph-reth/src/main.rs @@ -47,12 +47,6 @@ fn apply_morph_cli_defaults( .gas_price_oracle .default_suggested_fee .get_or_insert_with(morph_default_suggested_fee); - // reth ignores transactions from peers until its first canonical block after startup, - // including the one-off pool announcement each peer sends when a session opens, and - // peers do not repeat it. Blocks only ever arrive from the consensus client, so there is - // nothing to catch up on over p2p, and a restarted sequencer would otherwise never see - // the transactions RPC nodes held while it was down. - command.debug.startup_sync_state_idle = true; } } @@ -204,15 +198,4 @@ mod tests { assert!(!command.ext.proofs_history); assert_eq!(command.rpc.rpc_eth_proof_window, 0); } - - #[test] - fn node_command_accepts_peer_transactions_from_startup() { - let mut cli = MorphCli::try_parse_from(["morph-reth", "node", "--chain", "hoodi"]) - .expect("node command must parse"); - apply_morph_cli_defaults(&mut cli); - let Commands::Node(command) = cli.command else { - panic!("expected node command") - }; - assert!(command.debug.startup_sync_state_idle); - } } diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index afd7699..d06be88 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -33,6 +33,7 @@ reth-engine-tree.workspace = true reth-errors.workspace = true reth-evm.workspace = true reth-execution-cache.workspace = true +reth-network.workspace = true reth-node-api.workspace = true reth-node-builder.workspace = true reth-node-ethereum.workspace = true diff --git a/crates/node/src/components/mod.rs b/crates/node/src/components/mod.rs index 7ef3716..e30cbca 100644 --- a/crates/node/src/components/mod.rs +++ b/crates/node/src/components/mod.rs @@ -2,16 +2,19 @@ //! //! This module provides builders for the various components that make up a Morph node: //! - [`MorphPoolBuilder`]: Transaction pool with L1 fee validation +//! - [`MorphNetworkBuilder`]: P2P network that accepts transaction gossip from start-up //! - [`MorphExecutorBuilder`]: EVM executor with Morph-specific logic //! - [`MorphConsensusBuilder`]: Consensus validation for L2 blocks //! - [`MorphPayloadBuilderBuilder`]: Block building with L1 message handling mod consensus; mod executor; +mod network; mod payload; mod pool; pub use consensus::MorphConsensusBuilder; pub use executor::MorphExecutorBuilder; +pub use network::MorphNetworkBuilder; pub use payload::MorphPayloadBuilderBuilder; pub use pool::MorphPoolBuilder; diff --git a/crates/node/src/components/network.rs b/crates/node/src/components/network.rs new file mode 100644 index 0000000..ecfea8b --- /dev/null +++ b/crates/node/src/components/network.rs @@ -0,0 +1,46 @@ +//! Morph network builder. + +use reth_network::{NetworkSyncUpdater, SyncState}; +use reth_node_api::FullNodeTypes; +use reth_node_builder::{BuilderContext, components::NetworkBuilder}; +use reth_node_ethereum::EthereumNetworkBuilder; +use reth_transaction_pool::TransactionPool; + +/// Builder for the P2P network. +/// +/// Builds the standard reth network and accepts transaction gossip from start-up instead of +/// from the first block the consensus client imports. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct MorphNetworkBuilder; + +impl NetworkBuilder for MorphNetworkBuilder +where + Node: FullNodeTypes, + Pool: TransactionPool, + EthereumNetworkBuilder: NetworkBuilder, +{ + type Network = >::Network; + + async fn build_network( + self, + ctx: &BuilderContext, + pool: Pool, + ) -> eyre::Result { + let network = EthereumNetworkBuilder::default() + .build_network(ctx, pool) + .await?; + + // reth ignores peer transactions while the network is initially syncing: from the + // `Syncing` state the launcher sets on every start until the first switch to `Idle`, + // which otherwise waits for the first block the consensus client imports. That window + // drops the pool each peer announces only once, when its session opens, so a restarted + // sequencer would never see what RPC nodes held while it was down. Blocks arrive through + // the engine API only, so there is no p2p sync to wait for: switching once here, before + // any session can open, marks the initial sync done for the life of the process. + network.update_sync_state(SyncState::Syncing); + network.update_sync_state(SyncState::Idle); + + Ok(network) + } +} diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index 87fee57..ebdd7e6 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -7,6 +7,7 @@ //! //! The node is assembled from the following builders: //! - [`MorphPoolBuilder`]: Transaction pool with L1 fee validation +//! - [`MorphNetworkBuilder`]: P2P network that accepts transaction gossip from start-up //! - [`MorphExecutorBuilder`]: EVM executor with Morph-specific logic //! - [`MorphConsensusBuilder`]: Consensus validation for L2 blocks //! - [`MorphPayloadBuilderBuilder`]: Block building with L1 message handling @@ -16,7 +17,8 @@ use super::{ add_ons::MorphAddOns, args::MorphArgs, components::{ - MorphConsensusBuilder, MorphExecutorBuilder, MorphPayloadBuilderBuilder, MorphPoolBuilder, + MorphConsensusBuilder, MorphExecutorBuilder, MorphNetworkBuilder, + MorphPayloadBuilderBuilder, MorphPoolBuilder, }, }; use alloy_consensus::BlockHeader; @@ -32,7 +34,6 @@ use reth_node_builder::{ DebugNode, Node, NodeAdapter, components::{BasicPayloadServiceBuilder, ComponentsBuilder}, }; -use reth_node_ethereum::EthereumNetworkBuilder; use reth_payload_primitives::PayloadAttributesBuilder; use reth_primitives_traits::SealedHeader; use reth_provider::{ @@ -73,7 +74,7 @@ impl MorphNode { N, MorphPoolBuilder, BasicPayloadServiceBuilder, - EthereumNetworkBuilder, + MorphNetworkBuilder, MorphExecutorBuilder, MorphConsensusBuilder, > @@ -87,7 +88,7 @@ impl MorphNode { .payload(BasicPayloadServiceBuilder::new( MorphPayloadBuilderBuilder::new(payload_builder_config), )) - .network(EthereumNetworkBuilder::default()) + .network(MorphNetworkBuilder::default()) .consensus(MorphConsensusBuilder::default()) } } @@ -110,7 +111,7 @@ where N, MorphPoolBuilder, BasicPayloadServiceBuilder, - EthereumNetworkBuilder, + MorphNetworkBuilder, MorphExecutorBuilder, MorphConsensusBuilder, >; diff --git a/crates/node/tests/it/main.rs b/crates/node/tests/it/main.rs index 6e532c6..8f1c33f 100644 --- a/crates/node/tests/it/main.rs +++ b/crates/node/tests/it/main.rs @@ -16,6 +16,7 @@ mod invalid_payload_recovery; mod l1_messages; mod mixed_block_pressure; mod morph_tx; +mod network; mod proof_history; mod reference_index; mod rpc; diff --git a/crates/node/tests/it/network.rs b/crates/node/tests/it/network.rs new file mode 100644 index 0000000..097bd98 --- /dev/null +++ b/crates/node/tests/it/network.rs @@ -0,0 +1,46 @@ +//! P2P network E2E tests. + +use morph_node::test_utils::{TestNodeBuilder, make_transfer_tx, wallet_at_index}; +use reth_network::NetworkInfo; +use reth_provider::BlockNumReader; +use reth_transaction_pool::TransactionPool; + +use super::helpers::{NETWORK_POLL_BUDGET, POLL_INTERVAL}; + +/// A transaction a peer already holds when the session opens reaches a node that has not +/// imported a block yet. +/// +/// Peers announce their pool only once, when the session opens. If the node dropped that +/// announcement until its first block, a sequencer restarting while RPC nodes hold pending +/// transactions would never receive them. +#[tokio::test(flavor = "multi_thread")] +async fn peer_pool_reaches_node_before_first_block() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + // Built separately so they are not connected until the transaction is pending. + let (mut sequencers, wallet) = TestNodeBuilder::new().build().await?; + let (mut rpcs, _) = TestNodeBuilder::new().build().await?; + let mut sequencer = sequencers.pop().unwrap(); + let mut rpc = rpcs.pop().unwrap(); + + let tx = make_transfer_tx(wallet.chain_id, wallet_at_index(1, wallet.chain_id), 0).await; + let tx_hash = rpc.rpc.inject_tx(tx).await?; + + assert!( + sequencer.inner.network.is_syncing(), + "the node must still be in its start-up sync state" + ); + sequencer.connect(&mut rpc).await; + + let deadline = tokio::time::Instant::now() + NETWORK_POLL_BUDGET; + while !sequencer.inner.pool.contains(&tx_hash) { + assert!( + tokio::time::Instant::now() < deadline, + "the peer's pending transaction never reached the node" + ); + tokio::time::sleep(POLL_INTERVAL).await; + } + assert_eq!(sequencer.inner.provider.best_block_number()?, 0); + + Ok(()) +} From db5565a13634593974d49911d7757be2fa7fb9fe Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Thu, 24 Sep 2026 23:48:18 +0800 Subject: [PATCH 3/3] test(node): cover peer gossip during initial backfill --- crates/node/src/test_utils.rs | 22 +++++++++++++++ crates/node/tests/it/network.rs | 50 ++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index 24d2256..623e4ea 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -204,6 +204,8 @@ pub struct TestNodeBuilder { num_nodes: usize, is_dev: bool, desired_gas_limit: Option, + debug_tip: Option, + trusted_peer: Option, morph_args: Option, } @@ -229,6 +231,8 @@ impl TestNodeBuilder { num_nodes: 1, is_dev: false, desired_gas_limit: None, + debug_tip: None, + trusted_peer: None, morph_args: None, } } @@ -285,6 +289,18 @@ impl TestNodeBuilder { self } + /// Start an initial pipeline backfill toward the given block hash. + pub fn with_debug_tip(mut self, tip: B256) -> Self { + self.debug_tip = Some(tip); + self + } + + /// Dial a trusted peer during node startup, before an initial backfill runs. + pub fn with_trusted_peer(mut self, enode: impl Into) -> Self { + self.trusted_peer = Some(enode.into()); + self + } + /// Override the maximum pool-transaction payload bytes included in a block. /// /// Restricted to single-node setups. @@ -323,6 +339,8 @@ impl TestNodeBuilder { // 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; + let debug_tip = self.debug_tip; + let trusted_peer = self.trusted_peer; reth_e2e_test_utils::E2ETestSetupBuilder::::new( self.num_nodes, Arc::new(chain_spec), @@ -330,6 +348,10 @@ impl TestNodeBuilder { ) .with_node_config_modifier(move |mut config| { config.builder.gas_limit = desired_gas_limit; + config.debug.tip = debug_tip; + if let Some(ref enode) = trusted_peer { + config.network.trusted_peers = vec![enode.parse().expect("valid trusted enode")]; + } config.set_dev(is_dev) }) .build() diff --git a/crates/node/tests/it/network.rs b/crates/node/tests/it/network.rs index 097bd98..6766d63 100644 --- a/crates/node/tests/it/network.rs +++ b/crates/node/tests/it/network.rs @@ -5,7 +5,8 @@ use reth_network::NetworkInfo; use reth_provider::BlockNumReader; use reth_transaction_pool::TransactionPool; -use super::helpers::{NETWORK_POLL_BUDGET, POLL_INTERVAL}; +use super::helpers::{NETWORK_POLL_BUDGET, POLL_INTERVAL, assemble_l2_block, import_l2_block}; +use morph_payload_types::AssembleL2BlockParams; /// A transaction a peer already holds when the session opens reaches a node that has not /// imported a block yet. @@ -44,3 +45,50 @@ async fn peer_pool_reaches_node_before_first_block() -> eyre::Result<()> { Ok(()) } + +/// A node starting pipeline backfill can still receive a peer's pending pool +/// announcement while its chain is catching up. +#[tokio::test(flavor = "multi_thread")] +async fn peer_pool_reaches_node_during_initial_backfill() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut sources, wallet) = TestNodeBuilder::new().build().await?; + let source = sources.pop().unwrap(); + let mut tip = None; + for number in 1..=5 { + let mut params = AssembleL2BlockParams::empty(number); + params.timestamp = Some(number); + let block = assemble_l2_block(&source, params).await?; + import_l2_block(&source, block.clone()).await?; + tip = Some(block.hash); + } + let tip = tip.expect("five blocks were imported"); + + let tx = make_transfer_tx(wallet.chain_id, wallet_at_index(1, wallet.chain_id), 0).await; + let tx_hash = source.rpc.inject_tx(tx).await?; + + // Launch must be able to dial the source during backfill: the upstream + // launcher can wait for that initial backfill before returning the handle. + let enode = source.network.record().to_string(); + let (mut followers, _) = tokio::time::timeout( + NETWORK_POLL_BUDGET, + TestNodeBuilder::new() + .with_debug_tip(tip) + .with_trusted_peer(enode) + .build(), + ) + .await??; + let follower = followers.pop().unwrap(); + let deadline = tokio::time::Instant::now() + NETWORK_POLL_BUDGET; + while !follower.inner.pool.contains(&tx_hash) + || follower.inner.provider.best_block_number()? < 5 + { + assert!( + tokio::time::Instant::now() < deadline, + "backfill did not finish with the peer's pending transaction in the pool" + ); + tokio::time::sleep(POLL_INTERVAL).await; + } + + Ok(()) +}