Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/e2e-test-utils/src/testsuite/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ pub mod reorg;
pub use fork::{CreateFork, ForkBase, SetForkBase, SetForkBaseFromBlockInfo, ValidateFork};
pub use produce_blocks::{
AssertMineBlock, BroadcastLatestForkchoice, BroadcastNextNewPayload, CheckPayloadAccepted,
GenerateNextPayload, GeneratePayloadAttributes, PickNextBlockProducer, ProduceBlocks,
UpdateBlockInfo, UpdateBlockInfoToLatestPayload,
ExpectFcuStatus, GenerateNextPayload, GeneratePayloadAttributes, PickNextBlockProducer,
ProduceBlocks, ProduceInvalidBlocks, TestFcuToTag, UpdateBlockInfo,
UpdateBlockInfoToLatestPayload, ValidateCanonicalTag,
};
pub use reorg::{ReorgTarget, ReorgTo, SetReorgTarget};

Expand Down
270 changes: 269 additions & 1 deletion crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use eyre::Result;
use futures_util::future::BoxFuture;
use reth_node_api::{EngineTypes, PayloadTypes};
use reth_rpc_api::clients::{EngineApiClient, EthApiClient};
use std::{marker::PhantomData, time::Duration};
use std::{collections::HashSet, marker::PhantomData, time::Duration};
use tokio::time::sleep;
use tracing::debug;

Expand Down Expand Up @@ -697,3 +697,271 @@ where
})
}
}

/// Action to test forkchoice update to a tagged block with expected status
#[derive(Debug)]
pub struct TestFcuToTag {
/// Tag name of the target block
pub tag: String,
/// Expected payload status
pub expected_status: PayloadStatusEnum,
}

impl TestFcuToTag {
/// Create a new `TestFcuToTag` action
pub fn new(tag: impl Into<String>, expected_status: PayloadStatusEnum) -> Self {
Self { tag: tag.into(), expected_status }
}
}

impl<Engine> Action<Engine> for TestFcuToTag
where
Engine: EngineTypes,
{
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
// get the target block from the registry
let target_block = env
.block_registry
.get(&self.tag)
.copied()
.ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?;

let engine_client = env.node_clients[0].engine.http_client();
let fcu_state = ForkchoiceState {
head_block_hash: target_block.hash,
safe_block_hash: target_block.hash,
finalized_block_hash: target_block.hash,
};

let fcu_response =
EngineApiClient::<Engine>::fork_choice_updated_v2(&engine_client, fcu_state, None)
.await?;

// validate the response matches expected status
match (&fcu_response.payload_status.status, &self.expected_status) {
(PayloadStatusEnum::Valid, PayloadStatusEnum::Valid) => {
debug!("FCU to '{}' returned VALID as expected", self.tag);
}
(PayloadStatusEnum::Invalid { .. }, PayloadStatusEnum::Invalid { .. }) => {
debug!("FCU to '{}' returned INVALID as expected", self.tag);
}
(PayloadStatusEnum::Syncing, PayloadStatusEnum::Syncing) => {
debug!("FCU to '{}' returned SYNCING as expected", self.tag);
}
(PayloadStatusEnum::Accepted, PayloadStatusEnum::Accepted) => {
debug!("FCU to '{}' returned ACCEPTED as expected", self.tag);
}
(actual, expected) => {
return Err(eyre::eyre!(
"FCU to '{}': expected status {:?}, but got {:?}",
self.tag,
expected,
actual
));
}
}

Ok(())
})
}
}

/// Action to expect a specific FCU status when targeting a tagged block
#[derive(Debug)]
pub struct ExpectFcuStatus {
/// Tag name of the target block
pub target_tag: String,
/// Expected payload status
pub expected_status: PayloadStatusEnum,
}

impl ExpectFcuStatus {
/// Create a new `ExpectFcuStatus` action expecting VALID status
pub fn valid(target_tag: impl Into<String>) -> Self {
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Valid }
}

/// Create a new `ExpectFcuStatus` action expecting INVALID status
pub fn invalid(target_tag: impl Into<String>) -> Self {
Self {
target_tag: target_tag.into(),
expected_status: PayloadStatusEnum::Invalid {
validation_error: "corrupted block".to_string(),
},
}
}

/// Create a new `ExpectFcuStatus` action expecting SYNCING status
pub fn syncing(target_tag: impl Into<String>) -> Self {
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Syncing }
}

/// Create a new `ExpectFcuStatus` action expecting ACCEPTED status
pub fn accepted(target_tag: impl Into<String>) -> Self {
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Accepted }
}
}

impl<Engine> Action<Engine> for ExpectFcuStatus
where
Engine: EngineTypes,
{
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let mut test_fcu = TestFcuToTag::new(&self.target_tag, self.expected_status.clone());
test_fcu.execute(env).await
})
}
}

/// Action to validate that a tagged block remains canonical by performing FCU to it
#[derive(Debug)]
pub struct ValidateCanonicalTag {
/// Tag name of the block to validate as canonical
pub tag: String,
}

impl ValidateCanonicalTag {
/// Create a new `ValidateCanonicalTag` action
pub fn new(tag: impl Into<String>) -> Self {
Self { tag: tag.into() }
}
}

impl<Engine> Action<Engine> for ValidateCanonicalTag
where
Engine: EngineTypes,
{
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let mut expect_valid = ExpectFcuStatus::valid(&self.tag);
expect_valid.execute(env).await?;

debug!("Successfully validated that '{}' remains canonical", self.tag);
Ok(())
})
}
}

/// Action that produces a sequence of blocks where some blocks are intentionally invalid
#[derive(Debug)]
pub struct ProduceInvalidBlocks<Engine> {
/// Number of blocks to produce
pub num_blocks: u64,
/// Set of indices (0-based) where blocks should be made invalid
pub invalid_indices: HashSet<u64>,
/// Tracks engine type
_phantom: PhantomData<Engine>,
}

impl<Engine> ProduceInvalidBlocks<Engine> {
/// Create a new `ProduceInvalidBlocks` action
pub fn new(num_blocks: u64, invalid_indices: HashSet<u64>) -> Self {
Self { num_blocks, invalid_indices, _phantom: Default::default() }
}

/// Create a new `ProduceInvalidBlocks` action with a single invalid block at the specified
/// index
pub fn with_invalid_at(num_blocks: u64, invalid_index: u64) -> Self {
let mut invalid_indices = HashSet::new();
invalid_indices.insert(invalid_index);
Self::new(num_blocks, invalid_indices)
}
}

impl<Engine> Action<Engine> for ProduceInvalidBlocks<Engine>
where
Engine: EngineTypes + PayloadTypes,
Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
{
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
for block_index in 0..self.num_blocks {
let is_invalid = self.invalid_indices.contains(&block_index);

if is_invalid {
debug!("Producing invalid block at index {}", block_index);

// produce a valid block first, then corrupt it
let mut sequence = Sequence::new(vec![
Box::new(PickNextBlockProducer::default()),
Box::new(GeneratePayloadAttributes::default()),
Box::new(GenerateNextPayload::default()),
]);
sequence.execute(env).await?;

// get the latest payload and corrupt it
let latest_envelope = env
.latest_payload_envelope
.as_ref()
.ok_or_else(|| eyre::eyre!("No payload envelope available to corrupt"))?;

let envelope_v3: ExecutionPayloadEnvelopeV3 = latest_envelope.clone().into();
let mut corrupted_payload = envelope_v3.execution_payload;

// corrupt the state root to make the block invalid
corrupted_payload.payload_inner.payload_inner.state_root = B256::random();

debug!(
"Corrupted state root for block {} to: {}",
block_index, corrupted_payload.payload_inner.payload_inner.state_root
);

// send the corrupted payload via newPayload
let engine_client = env.node_clients[0].engine.http_client();
// for simplicity, we'll use empty versioned hashes for invalid block testing
let versioned_hashes = Vec::new();
// use a random parent beacon block root since this is for invalid block testing
let parent_beacon_block_root = B256::random();

let new_payload_response = EngineApiClient::<Engine>::new_payload_v3(
&engine_client,
corrupted_payload.clone(),
versioned_hashes,
parent_beacon_block_root,
)
.await?;

// expect the payload to be rejected as invalid
match new_payload_response.status {
PayloadStatusEnum::Invalid { validation_error } => {
debug!(
"Block {} correctly rejected as invalid: {:?}",
block_index, validation_error
);
}
other_status => {
return Err(eyre::eyre!(
"Expected block {} to be rejected as INVALID, but got: {:?}",
block_index,
other_status
));
}
}

// update block info with the corrupted block (for potential future reference)
env.current_block_info = Some(BlockInfo {
hash: corrupted_payload.payload_inner.payload_inner.block_hash,
number: corrupted_payload.payload_inner.payload_inner.block_number,
timestamp: corrupted_payload.timestamp(),
});
} else {
debug!("Producing valid block at index {}", block_index);

// produce a valid block normally
let mut sequence = Sequence::new(vec![
Box::new(PickNextBlockProducer::default()),
Box::new(GeneratePayloadAttributes::default()),
Box::new(GenerateNextPayload::default()),
Box::new(BroadcastNextNewPayload::default()),
Box::new(UpdateBlockInfoToLatestPayload::default()),
]);
sequence.execute(env).await?;
}
}
Ok(())
})
}
}
44 changes: 43 additions & 1 deletion crates/engine/tree/src/tree/e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::tree::TreeConfig;
use eyre::Result;
use reth_chainspec::{ChainSpecBuilder, MAINNET};
use reth_e2e_test_utils::testsuite::{
actions::{CaptureBlock, CreateFork, MakeCanonical, ProduceBlocks, ReorgTo},
actions::{
CaptureBlock, CreateFork, ExpectFcuStatus, MakeCanonical, ProduceBlocks,
ProduceInvalidBlocks, ReorgTo, ValidateCanonicalTag,
},
setup::{NetworkSetup, Setup},
TestBuilder,
};
Expand Down Expand Up @@ -109,3 +112,42 @@ async fn test_engine_tree_valid_forks_with_older_canonical_head_e2e() -> Result<

Ok(())
}

/// Test that verifies valid and invalid forks with an older canonical head.
#[tokio::test]
async fn test_engine_tree_valid_and_invalid_forks_with_older_canonical_head_e2e() -> Result<()> {
reth_tracing::init_test_tracing();

let test = TestBuilder::new()
.with_setup(default_engine_tree_setup())
// create base chain with 1 block (old head)
.with_action(ProduceBlocks::<EthEngineTypes>::new(1))
.with_action(CaptureBlock::new("old_head"))
.with_action(MakeCanonical::new())
// extend base chain with 5 more blocks to establish fork point
.with_action(ProduceBlocks::<EthEngineTypes>::new(5))
.with_action(CaptureBlock::new("fork_point"))
.with_action(MakeCanonical::new())
// revert to old head to simulate older canonical head scenario
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("old_head"))
// create chain B (the valid chain) from fork point with 10 blocks
.with_action(CreateFork::<EthEngineTypes>::new_from_tag("fork_point", 10))
.with_action(CaptureBlock::new("chain_b_tip"))
// make chain B canonical via FCU - this becomes the valid chain
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("chain_b_tip"))
// create chain A (competing chain) - first produce valid blocks, then test invalid
// scenario
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("fork_point"))
.with_action(ProduceBlocks::<EthEngineTypes>::new(10))
.with_action(CaptureBlock::new("chain_a_tip"))
// test that FCU to chain A tip returns VALID status (it's a valid competing chain)
.with_action(ExpectFcuStatus::valid("chain_a_tip"))
// attempt to produce invalid blocks (which should be rejected)
.with_action(ProduceInvalidBlocks::<EthEngineTypes>::with_invalid_at(3, 2))
// chain B remains the canonical chain
.with_action(ValidateCanonicalTag::new("chain_b_tip"));

test.run::<EthereumNode>().await?;

Ok(())
}
Loading
Loading