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 e12a5fe..3a4cfba 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 09e6186..992e4de 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 3a4cfba..2724217 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 992e4de..a2ac8aa 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 18ed5b5..f770ca6 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 2724217..1de4a5f 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 f770ca6..16d014c 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 a2ac8aa..bb0cb2f 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 16d014c..4311845 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 4311845..eee0bb2 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 8a7b026..b36dab9 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 f171ca7..d062c5e 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();