From 18bda4c63539fa234d48e816733893f39b544226 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 15:06:52 +0100 Subject: [PATCH 1/6] fix: restrict txpool fallback to dev mode only The payload builder was pulling transactions from the txpool whenever Engine API attributes had none, which could happen in production and break consensus (non-deterministic block contents). This change: - Adds a dev_mode boolean to EvolvePayloadBuilderConfig - Sets it to true when running with --dev flag (detected via ctx.is_dev()) - Guards the txpool fallback behind this flag In production (without --dev), the payload builder always uses only the transactions from Engine API attributes, preventing any non-deterministic behavior. --- crates/node/src/config.rs | 25 +++++++++---------------- crates/node/src/payload_service.rs | 10 ++++++---- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index e12a5fec..3a4cfba9 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -59,6 +59,9 @@ pub struct EvolvePayloadBuilderConfig { /// Block height at which deploy allowlist enforcement activates. #[serde(default)] pub deploy_allowlist_activation_height: Option, + /// Enables dev-mode behaviour (e.g. pulling txpool transactions into blocks). + #[serde(default)] + pub dev_mode: bool, } impl EvolvePayloadBuilderConfig { @@ -73,6 +76,7 @@ impl EvolvePayloadBuilderConfig { contract_size_limit_activation_height: None, deploy_allowlist: Vec::new(), deploy_allowlist_activation_height: None, + dev_mode: false, } } @@ -118,7 +122,9 @@ impl EvolvePayloadBuilderConfig { config.deploy_allowlist_activation_height = Some(0); } } + } + Ok(config) } @@ -400,10 +406,7 @@ mod tests { mint_admin: Some(address!("00000000000000000000000000000000000000aa")), base_fee_redirect_activation_height: Some(0), mint_precompile_activation_height: Some(0), - contract_size_limit: None, - contract_size_limit_activation_height: None, - deploy_allowlist: Vec::new(), - deploy_allowlist_activation_height: None, + ..Default::default() }; assert!(config_with_sink.validate().is_ok()); } @@ -468,14 +471,9 @@ mod tests { allowlist.push(addr); } let config = EvolvePayloadBuilderConfig { - base_fee_sink: None, - mint_admin: None, - base_fee_redirect_activation_height: None, - mint_precompile_activation_height: None, - contract_size_limit: None, - contract_size_limit_activation_height: None, deploy_allowlist: allowlist, deploy_allowlist_activation_height: Some(0), + ..Default::default() }; assert!(matches!( @@ -489,13 +487,8 @@ mod tests { let sink = address!("0000000000000000000000000000000000000003"); let mut config = EvolvePayloadBuilderConfig { base_fee_sink: Some(sink), - mint_admin: None, base_fee_redirect_activation_height: Some(5), - mint_precompile_activation_height: None, - contract_size_limit: None, - contract_size_limit_activation_height: None, - deploy_allowlist: Vec::new(), - deploy_allowlist_activation_height: None, + ..Default::default() }; assert_eq!(config.base_fee_sink_for_block(4), None); diff --git a/crates/node/src/payload_service.rs b/crates/node/src/payload_service.rs index 09e61866..992e4def 100644 --- a/crates/node/src/payload_service.rs +++ b/crates/node/src/payload_service.rs @@ -105,6 +105,7 @@ where self.config.mint_precompile_activation_height; } + config.dev_mode = ctx.is_dev(); config.validate()?; let evolve_builder = Arc::new(EvolvePayloadBuilder::new( @@ -180,9 +181,10 @@ where } } - // Use transactions from Engine API attributes if provided, otherwise pull from the pool - // (e.g. in --dev mode where LocalMiner sends empty attributes). - let transactions = if attributes.transactions.is_empty() { + // In dev mode, pull pending transactions from the txpool when the Engine API + // attributes contain none (LocalMiner sends empty attributes). + // In production this is disabled to prevent non-deterministic block contents. + let transactions = if self.config.dev_mode && attributes.transactions.is_empty() { let pool_txs: Vec = self .pool .pending_transactions() @@ -192,7 +194,7 @@ where if !pool_txs.is_empty() { info!( pool_tx_count = pool_txs.len(), - "pulling transactions from pool" + "pulling transactions from pool (dev mode)" ); } pool_txs From 75746fa33edb6cd21486727a4bb00e41876073bd Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 15:11:05 +0100 Subject: [PATCH 2/6] fix: restrict txpool fallback to dev mode only The payload builder was pulling transactions from the txpool whenever Engine API attributes had none, which could break consensus by producing non-deterministic block contents in production. Guard the txpool fallback behind ctx.is_dev() so it only activates when the node is running with --dev flag. --- crates/node/src/config.rs | 4 ---- crates/node/src/payload_service.rs | 7 +++++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 3a4cfba9..27242174 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -59,9 +59,6 @@ pub struct EvolvePayloadBuilderConfig { /// Block height at which deploy allowlist enforcement activates. #[serde(default)] pub deploy_allowlist_activation_height: Option, - /// Enables dev-mode behaviour (e.g. pulling txpool transactions into blocks). - #[serde(default)] - pub dev_mode: bool, } impl EvolvePayloadBuilderConfig { @@ -76,7 +73,6 @@ impl EvolvePayloadBuilderConfig { contract_size_limit_activation_height: None, deploy_allowlist: Vec::new(), deploy_allowlist_activation_height: None, - dev_mode: false, } } diff --git a/crates/node/src/payload_service.rs b/crates/node/src/payload_service.rs index 992e4def..a2ac8aa2 100644 --- a/crates/node/src/payload_service.rs +++ b/crates/node/src/payload_service.rs @@ -63,6 +63,7 @@ where pub(crate) evolve_builder: Arc>, pub(crate) config: EvolvePayloadBuilderConfig, pub(crate) pool: Pool, + pub(crate) dev_mode: bool, } impl PayloadBuilderBuilder for EvolvePayloadBuilderBuilder @@ -105,7 +106,6 @@ where self.config.mint_precompile_activation_height; } - config.dev_mode = ctx.is_dev(); config.validate()?; let evolve_builder = Arc::new(EvolvePayloadBuilder::new( @@ -118,6 +118,7 @@ where evolve_builder, config, pool, + dev_mode: ctx.is_dev(), }) } } @@ -184,7 +185,7 @@ where // In dev mode, pull pending transactions from the txpool when the Engine API // attributes contain none (LocalMiner sends empty attributes). // In production this is disabled to prevent non-deterministic block contents. - let transactions = if self.config.dev_mode && attributes.transactions.is_empty() { + let transactions = if self.dev_mode && attributes.transactions.is_empty() { let pool_txs: Vec = self .pool .pending_transactions() @@ -384,6 +385,7 @@ mod tests { evolve_builder, config, pool: NoopTransactionPool::::new(), + dev_mode: false, }; let rpc_attrs = RpcPayloadAttributes { @@ -474,6 +476,7 @@ mod tests { evolve_builder, config, pool: NoopTransactionPool::::new(), + dev_mode: false, }; let rpc_attrs = RpcPayloadAttributes { From f22942abcb9b685a801843221a54c209d934a980 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 15:59:49 +0100 Subject: [PATCH 3/6] test: add e2e test for dev mode txpool fallback --- crates/tests/src/e2e_tests.rs | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index 18ed5b56..f770ca67 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -2023,3 +2023,70 @@ async fn test_e2e_deploy_allowlist_blocks_unauthorized_deploys() -> Result<()> { Ok(()) } + +/// Tests that dev mode correctly enables the txpool fallback. +/// +/// When running with `--dev` flag, the payload builder should pull pending +/// transactions from the txpool when Engine API attributes contain no +/// transactions. This validates the full flow: +/// --dev flag → ctx.is_dev() → dev_mode on payload builder → txpool fallback +#[tokio::test(flavor = "multi_thread")] +async fn test_e2e_dev_mode_txpool_fallback() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = create_test_chain_spec(); + let chain_id = chain_spec.chain().id(); + + let mut setup = Setup::::default() + .with_chain_spec(chain_spec) + .with_network(NetworkSetup::single_node()) + .with_dev_mode(true) + .with_tree_config(e2e_test_tree_config()); + + let mut env = Environment::::default(); + setup.apply::(&mut env).await?; + + let parent_block = env.node_clients[0] + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .expect("parent block should exist"); + let mut parent_hash = parent_block.header.hash; + let mut parent_timestamp = parent_block.header.inner.timestamp; + let mut parent_number = parent_block.header.inner.number; + + // Create a signed transaction and send it to the txpool + let wallets = Wallet::new(1).with_chain_id(chain_id).wallet_gen(); + let sender = wallets.into_iter().next().unwrap(); + let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, sender).await; + + EthApiClient::::send_raw_transaction( + &env.node_clients[0].rpc, + raw_tx, + ) + .await?; + + // Build a block with empty transactions via Engine API. + // In dev mode, the payload builder pulls from the txpool. + let payload_envelope = build_block_with_transactions( + &mut env, + &mut parent_hash, + &mut parent_number, + &mut parent_timestamp, + None, + vec![], + Address::random(), + ) + .await?; + + let block_txs = &payload_envelope + .execution_payload + .payload_inner + .payload_inner + .transactions; + assert!( + !block_txs.is_empty(), + "dev mode should pull transaction from txpool when attributes are empty" + ); + + Ok(()) +} From 263e47633702c7ffcbd158cfd47045cfe40f285c Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 16:04:32 +0100 Subject: [PATCH 4/6] fix: resolve CI lint failures (fmt and clippy) --- crates/node/src/config.rs | 1 - crates/tests/src/e2e_tests.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 27242174..1de4a5fa 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -118,7 +118,6 @@ impl EvolvePayloadBuilderConfig { config.deploy_allowlist_activation_height = Some(0); } } - } Ok(config) diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index f770ca67..16d014c2 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -2029,7 +2029,7 @@ async fn test_e2e_deploy_allowlist_blocks_unauthorized_deploys() -> Result<()> { /// When running with `--dev` flag, the payload builder should pull pending /// transactions from the txpool when Engine API attributes contain no /// transactions. This validates the full flow: -/// --dev flag → ctx.is_dev() → dev_mode on payload builder → txpool fallback +/// `--dev` flag → `ctx.is_dev()` → `dev_mode` on payload builder → txpool fallback #[tokio::test(flavor = "multi_thread")] async fn test_e2e_dev_mode_txpool_fallback() -> Result<()> { reth_tracing::init_test_tracing(); From b89ab07827f3e563b135293c735ff4eac681c257 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 16:32:41 +0100 Subject: [PATCH 5/6] refactor: always use txpool in dev mode without empty check --- crates/node/src/payload_service.rs | 7 +++---- crates/tests/src/e2e_tests.rs | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/node/src/payload_service.rs b/crates/node/src/payload_service.rs index a2ac8aa2..bb0cb2f3 100644 --- a/crates/node/src/payload_service.rs +++ b/crates/node/src/payload_service.rs @@ -182,10 +182,9 @@ where } } - // In dev mode, pull pending transactions from the txpool when the Engine API - // attributes contain none (LocalMiner sends empty attributes). - // In production this is disabled to prevent non-deterministic block contents. - let transactions = if self.dev_mode && attributes.transactions.is_empty() { + // In dev mode, pull pending transactions from the txpool. + // In production, transactions come exclusively from Engine API attributes. + let transactions = if self.dev_mode { let pool_txs: Vec = self .pool .pending_transactions() diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index 16d014c2..43118454 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -2026,10 +2026,10 @@ async fn test_e2e_deploy_allowlist_blocks_unauthorized_deploys() -> Result<()> { /// Tests that dev mode correctly enables the txpool fallback. /// -/// When running with `--dev` flag, the payload builder should pull pending -/// transactions from the txpool when Engine API attributes contain no -/// transactions. This validates the full flow: -/// `--dev` flag → `ctx.is_dev()` → `dev_mode` on payload builder → txpool fallback +/// When running with `--dev` flag, the payload builder pulls pending +/// transactions from the txpool instead of relying on Engine API attributes. +/// This validates the full flow: +/// `--dev` flag → `ctx.is_dev()` → `dev_mode` on payload builder → txpool #[tokio::test(flavor = "multi_thread")] async fn test_e2e_dev_mode_txpool_fallback() -> Result<()> { reth_tracing::init_test_tracing(); From 52e3553207435e899a656fd02634c89b5a60ec02 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 4 Mar 2026 17:56:52 +0100 Subject: [PATCH 6/6] test: disable dev mode in e2e tests that use Engine API transactions --- crates/tests/src/e2e_tests.rs | 18 +++++++++--------- crates/tests/src/test_deploy_allowlist.rs | 2 +- crates/tests/src/test_evolve_engine_api.rs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/tests/src/e2e_tests.rs b/crates/tests/src/e2e_tests.rs index 43118454..eee0bb2d 100644 --- a/crates/tests/src/e2e_tests.rs +++ b/crates/tests/src/e2e_tests.rs @@ -273,7 +273,7 @@ async fn test_e2e_base_fee_sink_receives_base_fee() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -408,7 +408,7 @@ async fn test_e2e_sponsored_evnode_transaction() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -614,7 +614,7 @@ async fn test_e2e_invalid_sponsor_signature_skipped() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -739,7 +739,7 @@ async fn test_e2e_empty_calls_skipped() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -852,7 +852,7 @@ async fn test_e2e_sponsor_insufficient_max_fee_skipped() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -1000,7 +1000,7 @@ async fn test_e2e_nonce_bumped_on_create_batch_failure() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -1239,7 +1239,7 @@ async fn test_e2e_mint_and_burn_to_new_wallet() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec.clone()) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -1661,7 +1661,7 @@ async fn test_e2e_mint_precompile_via_contract() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec.clone()) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -1892,7 +1892,7 @@ async fn test_e2e_deploy_allowlist_blocks_unauthorized_deploys() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); diff --git a/crates/tests/src/test_deploy_allowlist.rs b/crates/tests/src/test_deploy_allowlist.rs index 8a7b0265..b36dab98 100644 --- a/crates/tests/src/test_deploy_allowlist.rs +++ b/crates/tests/src/test_deploy_allowlist.rs @@ -78,7 +78,7 @@ async fn test_e2e_deploy_allowlist_permits_create2_via_factory() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); diff --git a/crates/tests/src/test_evolve_engine_api.rs b/crates/tests/src/test_evolve_engine_api.rs index f171ca76..d062c5ec 100644 --- a/crates/tests/src/test_evolve_engine_api.rs +++ b/crates/tests/src/test_evolve_engine_api.rs @@ -75,7 +75,7 @@ async fn test_e2e_engine_api_fork_choice_with_transactions() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default(); @@ -250,7 +250,7 @@ async fn test_e2e_engine_api_gas_limit_handling() -> Result<()> { let mut setup = Setup::::default() .with_chain_spec(chain_spec) .with_network(NetworkSetup::single_node()) - .with_dev_mode(true) + .with_dev_mode(false) .with_tree_config(e2e_test_tree_config()); let mut env = Environment::::default();