From a01ecce73f712729d3a10bd3f8178cd2eb8bb44f Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 22 Jan 2026 16:55:36 +0100 Subject: [PATCH] test: add e2e tests for invalid payload handling via Engine API (#21288) --- .../node/tests/e2e/invalid_payload.rs | 357 ++++++++++++++++++ crates/ethereum/node/tests/e2e/main.rs | 1 + 2 files changed, 358 insertions(+) create mode 100644 crates/ethereum/node/tests/e2e/invalid_payload.rs diff --git a/crates/ethereum/node/tests/e2e/invalid_payload.rs b/crates/ethereum/node/tests/e2e/invalid_payload.rs new file mode 100644 index 0000000000..03269e53b7 --- /dev/null +++ b/crates/ethereum/node/tests/e2e/invalid_payload.rs @@ -0,0 +1,357 @@ +//! Tests for handling invalid payloads via Engine API. +//! +//! This module tests the scenario where a node receives invalid payloads (e.g., with modified +//! state roots) before receiving valid ones, ensuring the node can recover and continue. + +use crate::utils::eth_payload_attributes; +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ExecutionPayloadV3, PayloadStatusEnum}; +use rand::{rngs::StdRng, Rng, SeedableRng}; +use reth_chainspec::{ChainSpecBuilder, MAINNET}; +use reth_e2e_test_utils::{setup_engine, transaction::TransactionTestContext}; +use reth_node_ethereum::EthereumNode; + +use reth_rpc_api::EngineApiClient; +use std::sync::Arc; + +/// Tests that a node can handle receiving an invalid payload (with wrong state root) +/// followed by the correct payload, and continue operating normally. +/// +/// Setup: +/// - Node 1: Produces valid payloads and advances the chain +/// - Node 2: Receives payloads from node 1, but we also inject modified payloads with invalid state +/// roots in between to verify error handling +#[tokio::test] +async fn can_handle_invalid_payload_then_valid() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let seed: [u8; 32] = rand::rng().random(); + let mut rng = StdRng::from_seed(seed); + println!("Seed: {seed:?}"); + + let chain_spec = Arc::new( + ChainSpecBuilder::default() + .chain(MAINNET.chain) + .genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap()) + .cancun_activated() + .build(), + ); + + let (mut nodes, _tasks, wallet) = setup_engine::( + 2, + chain_spec.clone(), + false, + Default::default(), + eth_payload_attributes, + ) + .await?; + + let mut producer = nodes.pop().unwrap(); + let receiver = nodes.pop().unwrap(); + + // Get engine API client for the receiver node + let receiver_engine = receiver.auth_server_handle().http_client(); + + // Inject a transaction to allow block building (advance_block waits for transactions) + let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.inner).await; + producer.rpc.inject_tx(raw_tx).await?; + + // Build a valid payload on the producer + let payload = producer.advance_block().await?; + let valid_block = payload.block().clone(); + + // Create valid payload first, then corrupt the state root + let mut invalid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + let original_state_root = invalid_payload.payload_inner.payload_inner.state_root; + invalid_payload.payload_inner.payload_inner.state_root = B256::random_with(&mut rng); + + // Send the invalid payload to the receiver - should be rejected + let invalid_result = EngineApiClient::::new_payload_v3( + &receiver_engine, + invalid_payload.clone(), + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!( + "Invalid payload response: {:?} (state_root changed from {original_state_root} to {})", + invalid_result.status, invalid_payload.payload_inner.payload_inner.state_root + ); + + // The invalid payload should be rejected + assert!( + matches!( + invalid_result.status, + PayloadStatusEnum::Invalid { .. } | PayloadStatusEnum::Syncing + ), + "Expected INVALID or SYNCING status for invalid payload, got {:?}", + invalid_result.status + ); + + // Now send the valid payload - should be accepted + let valid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + + let valid_result = EngineApiClient::::new_payload_v3( + &receiver_engine, + valid_payload, + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!("Valid payload response: {:?}", valid_result.status); + + // The valid payload should be accepted + assert!( + matches!( + valid_result.status, + PayloadStatusEnum::Valid | PayloadStatusEnum::Syncing | PayloadStatusEnum::Accepted + ), + "Expected VALID/SYNCING/ACCEPTED status for valid payload, got {:?}", + valid_result.status + ); + + // Update forkchoice on receiver to the valid block + receiver.update_forkchoice(valid_block.hash(), valid_block.hash()).await?; + + // Verify the receiver node is at the expected block + let receiver_head = receiver.block_hash(1); + let producer_head = producer.block_hash(1); + assert_eq!( + receiver_head, producer_head, + "Receiver should have synced to the same chain as producer" + ); + + println!( + "Test passed: Receiver successfully handled invalid payloads and synced to valid chain" + ); + + Ok(()) +} + +/// Tests that a node can handle multiple consecutive invalid payloads +/// before receiving a valid one. +#[tokio::test] +async fn can_handle_multiple_invalid_payloads() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let seed: [u8; 32] = rand::rng().random(); + let mut rng = StdRng::from_seed(seed); + println!("Seed: {seed:?}"); + + let chain_spec = Arc::new( + ChainSpecBuilder::default() + .chain(MAINNET.chain) + .genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap()) + .cancun_activated() + .build(), + ); + + let (mut nodes, _tasks, wallet) = setup_engine::( + 2, + chain_spec.clone(), + false, + Default::default(), + eth_payload_attributes, + ) + .await?; + + let mut producer = nodes.pop().unwrap(); + let receiver = nodes.pop().unwrap(); + + let receiver_engine = receiver.auth_server_handle().http_client(); + + // Inject a transaction to allow block building + let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.inner).await; + producer.rpc.inject_tx(raw_tx).await?; + + // Produce a valid block + let payload = producer.advance_block().await?; + let valid_block = payload.block().clone(); + + // Send multiple invalid payloads with different corruptions + for i in 0..3 { + // Create valid payload first, then corrupt the state root + let mut invalid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + invalid_payload.payload_inner.payload_inner.state_root = B256::random_with(&mut rng); + + let result = EngineApiClient::::new_payload_v3( + &receiver_engine, + invalid_payload, + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!("Invalid payload {i}: status = {:?}", result.status); + + assert!( + matches!(result.status, PayloadStatusEnum::Invalid { .. } | PayloadStatusEnum::Syncing), + "Expected INVALID or SYNCING for invalid payload {i}, got {:?}", + result.status + ); + } + + // Now send the valid payload + let valid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + + let valid_result = EngineApiClient::::new_payload_v3( + &receiver_engine, + valid_payload, + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!("Valid payload: status = {:?}", valid_result.status); + + assert!( + matches!( + valid_result.status, + PayloadStatusEnum::Valid | PayloadStatusEnum::Syncing | PayloadStatusEnum::Accepted + ), + "Expected valid status for correct payload, got {:?}", + valid_result.status + ); + + // Finalize the valid block + receiver.update_forkchoice(valid_block.hash(), valid_block.hash()).await?; + + println!("Test passed: Receiver handled multiple invalid payloads and accepted valid one"); + + Ok(()) +} + +/// Tests invalid payload handling with blocks that contain transactions. +/// +/// This test sends real transactions to node 1, produces blocks with those transactions, +/// then sends invalid (corrupted state root) and valid payloads to node 2. +#[tokio::test] +async fn can_handle_invalid_payload_with_transactions() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let seed: [u8; 32] = rand::rng().random(); + let mut rng = StdRng::from_seed(seed); + println!("Seed: {seed:?}"); + + let chain_spec = Arc::new( + ChainSpecBuilder::default() + .chain(MAINNET.chain) + .genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap()) + .cancun_activated() + .build(), + ); + + let (mut nodes, _tasks, wallet) = setup_engine::( + 2, + chain_spec.clone(), + false, + Default::default(), + eth_payload_attributes, + ) + .await?; + + let mut producer = nodes.pop().unwrap(); + let receiver = nodes.pop().unwrap(); + + let receiver_engine = receiver.auth_server_handle().http_client(); + + // Create and send a transaction to the producer node + let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.inner).await; + let tx_hash = producer.rpc.inject_tx(raw_tx).await?; + println!("Injected transaction {tx_hash}"); + + // Build a block containing the transaction + let payload = producer.advance_block().await?; + let valid_block = payload.block().clone(); + + // Verify the block contains a transaction + let tx_count = valid_block.body().transactions().count(); + println!("Block contains {tx_count} transaction(s)"); + assert!(tx_count > 0, "Block should contain at least one transaction"); + + // Create invalid payload by corrupting the state root + let mut invalid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + let original_state_root = invalid_payload.payload_inner.payload_inner.state_root; + invalid_payload.payload_inner.payload_inner.state_root = B256::random_with(&mut rng); + + // Send invalid payload - should be rejected + let invalid_result = EngineApiClient::::new_payload_v3( + &receiver_engine, + invalid_payload.clone(), + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!( + "Invalid payload (with tx) response: {:?} (state_root changed from {original_state_root} to {})", + invalid_result.status, + invalid_payload.payload_inner.payload_inner.state_root + ); + + assert!( + matches!( + invalid_result.status, + PayloadStatusEnum::Invalid { .. } | PayloadStatusEnum::Syncing + ), + "Expected INVALID or SYNCING for invalid payload with transactions, got {:?}", + invalid_result.status + ); + + // Send valid payload - should be accepted + let valid_payload = ExecutionPayloadV3::from_block_unchecked( + valid_block.hash(), + &valid_block.clone().into_block(), + ); + + let valid_result = EngineApiClient::::new_payload_v3( + &receiver_engine, + valid_payload, + vec![], + valid_block.header().parent_beacon_block_root.unwrap_or_default(), + ) + .await?; + + println!("Valid payload (with tx) response: {:?}", valid_result.status); + + assert!( + matches!( + valid_result.status, + PayloadStatusEnum::Valid | PayloadStatusEnum::Syncing | PayloadStatusEnum::Accepted + ), + "Expected valid status for correct payload with transactions, got {:?}", + valid_result.status + ); + + // Update forkchoice + receiver.update_forkchoice(valid_block.hash(), valid_block.hash()).await?; + + // Verify both nodes are at the same head + let receiver_head = receiver.block_hash(1); + let producer_head = producer.block_hash(1); + assert_eq!( + receiver_head, producer_head, + "Receiver should have synced to the same chain as producer" + ); + + println!("Test passed: Receiver handled invalid payloads with transactions correctly"); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/main.rs b/crates/ethereum/node/tests/e2e/main.rs index 9ed9c5b9a6..5960cd9c6f 100644 --- a/crates/ethereum/node/tests/e2e/main.rs +++ b/crates/ethereum/node/tests/e2e/main.rs @@ -4,6 +4,7 @@ mod blobs; mod custom_genesis; mod dev; mod eth; +mod invalid_payload; mod p2p; mod pool; mod prestate;