Skip to content

Commit 1e27792

Browse files
authored
feat(test): rewrite test_engine_tree_valid_and_invalid_forks_with_older_canonical_head_e2e using e2e framework (#16705)
1 parent 2fccd08 commit 1e27792

File tree

4 files changed

+315
-102
lines changed

4 files changed

+315
-102
lines changed

crates/e2e-test-utils/src/testsuite/actions/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ pub mod reorg;
1515
pub use fork::{CreateFork, ForkBase, SetForkBase, SetForkBaseFromBlockInfo, ValidateFork};
1616
pub use produce_blocks::{
1717
AssertMineBlock, BroadcastLatestForkchoice, BroadcastNextNewPayload, CheckPayloadAccepted,
18-
GenerateNextPayload, GeneratePayloadAttributes, PickNextBlockProducer, ProduceBlocks,
19-
UpdateBlockInfo, UpdateBlockInfoToLatestPayload,
18+
ExpectFcuStatus, GenerateNextPayload, GeneratePayloadAttributes, PickNextBlockProducer,
19+
ProduceBlocks, ProduceInvalidBlocks, TestFcuToTag, UpdateBlockInfo,
20+
UpdateBlockInfoToLatestPayload, ValidateCanonicalTag,
2021
};
2122
pub use reorg::{ReorgTarget, ReorgTo, SetReorgTarget};
2223

crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs

Lines changed: 269 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use eyre::Result;
1313
use futures_util::future::BoxFuture;
1414
use reth_node_api::{EngineTypes, PayloadTypes};
1515
use reth_rpc_api::clients::{EngineApiClient, EthApiClient};
16-
use std::{marker::PhantomData, time::Duration};
16+
use std::{collections::HashSet, marker::PhantomData, time::Duration};
1717
use tokio::time::sleep;
1818
use tracing::debug;
1919

@@ -697,3 +697,271 @@ where
697697
})
698698
}
699699
}
700+
701+
/// Action to test forkchoice update to a tagged block with expected status
702+
#[derive(Debug)]
703+
pub struct TestFcuToTag {
704+
/// Tag name of the target block
705+
pub tag: String,
706+
/// Expected payload status
707+
pub expected_status: PayloadStatusEnum,
708+
}
709+
710+
impl TestFcuToTag {
711+
/// Create a new `TestFcuToTag` action
712+
pub fn new(tag: impl Into<String>, expected_status: PayloadStatusEnum) -> Self {
713+
Self { tag: tag.into(), expected_status }
714+
}
715+
}
716+
717+
impl<Engine> Action<Engine> for TestFcuToTag
718+
where
719+
Engine: EngineTypes,
720+
{
721+
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
722+
Box::pin(async move {
723+
// get the target block from the registry
724+
let target_block = env
725+
.block_registry
726+
.get(&self.tag)
727+
.copied()
728+
.ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?;
729+
730+
let engine_client = env.node_clients[0].engine.http_client();
731+
let fcu_state = ForkchoiceState {
732+
head_block_hash: target_block.hash,
733+
safe_block_hash: target_block.hash,
734+
finalized_block_hash: target_block.hash,
735+
};
736+
737+
let fcu_response =
738+
EngineApiClient::<Engine>::fork_choice_updated_v2(&engine_client, fcu_state, None)
739+
.await?;
740+
741+
// validate the response matches expected status
742+
match (&fcu_response.payload_status.status, &self.expected_status) {
743+
(PayloadStatusEnum::Valid, PayloadStatusEnum::Valid) => {
744+
debug!("FCU to '{}' returned VALID as expected", self.tag);
745+
}
746+
(PayloadStatusEnum::Invalid { .. }, PayloadStatusEnum::Invalid { .. }) => {
747+
debug!("FCU to '{}' returned INVALID as expected", self.tag);
748+
}
749+
(PayloadStatusEnum::Syncing, PayloadStatusEnum::Syncing) => {
750+
debug!("FCU to '{}' returned SYNCING as expected", self.tag);
751+
}
752+
(PayloadStatusEnum::Accepted, PayloadStatusEnum::Accepted) => {
753+
debug!("FCU to '{}' returned ACCEPTED as expected", self.tag);
754+
}
755+
(actual, expected) => {
756+
return Err(eyre::eyre!(
757+
"FCU to '{}': expected status {:?}, but got {:?}",
758+
self.tag,
759+
expected,
760+
actual
761+
));
762+
}
763+
}
764+
765+
Ok(())
766+
})
767+
}
768+
}
769+
770+
/// Action to expect a specific FCU status when targeting a tagged block
771+
#[derive(Debug)]
772+
pub struct ExpectFcuStatus {
773+
/// Tag name of the target block
774+
pub target_tag: String,
775+
/// Expected payload status
776+
pub expected_status: PayloadStatusEnum,
777+
}
778+
779+
impl ExpectFcuStatus {
780+
/// Create a new `ExpectFcuStatus` action expecting VALID status
781+
pub fn valid(target_tag: impl Into<String>) -> Self {
782+
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Valid }
783+
}
784+
785+
/// Create a new `ExpectFcuStatus` action expecting INVALID status
786+
pub fn invalid(target_tag: impl Into<String>) -> Self {
787+
Self {
788+
target_tag: target_tag.into(),
789+
expected_status: PayloadStatusEnum::Invalid {
790+
validation_error: "corrupted block".to_string(),
791+
},
792+
}
793+
}
794+
795+
/// Create a new `ExpectFcuStatus` action expecting SYNCING status
796+
pub fn syncing(target_tag: impl Into<String>) -> Self {
797+
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Syncing }
798+
}
799+
800+
/// Create a new `ExpectFcuStatus` action expecting ACCEPTED status
801+
pub fn accepted(target_tag: impl Into<String>) -> Self {
802+
Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Accepted }
803+
}
804+
}
805+
806+
impl<Engine> Action<Engine> for ExpectFcuStatus
807+
where
808+
Engine: EngineTypes,
809+
{
810+
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
811+
Box::pin(async move {
812+
let mut test_fcu = TestFcuToTag::new(&self.target_tag, self.expected_status.clone());
813+
test_fcu.execute(env).await
814+
})
815+
}
816+
}
817+
818+
/// Action to validate that a tagged block remains canonical by performing FCU to it
819+
#[derive(Debug)]
820+
pub struct ValidateCanonicalTag {
821+
/// Tag name of the block to validate as canonical
822+
pub tag: String,
823+
}
824+
825+
impl ValidateCanonicalTag {
826+
/// Create a new `ValidateCanonicalTag` action
827+
pub fn new(tag: impl Into<String>) -> Self {
828+
Self { tag: tag.into() }
829+
}
830+
}
831+
832+
impl<Engine> Action<Engine> for ValidateCanonicalTag
833+
where
834+
Engine: EngineTypes,
835+
{
836+
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
837+
Box::pin(async move {
838+
let mut expect_valid = ExpectFcuStatus::valid(&self.tag);
839+
expect_valid.execute(env).await?;
840+
841+
debug!("Successfully validated that '{}' remains canonical", self.tag);
842+
Ok(())
843+
})
844+
}
845+
}
846+
847+
/// Action that produces a sequence of blocks where some blocks are intentionally invalid
848+
#[derive(Debug)]
849+
pub struct ProduceInvalidBlocks<Engine> {
850+
/// Number of blocks to produce
851+
pub num_blocks: u64,
852+
/// Set of indices (0-based) where blocks should be made invalid
853+
pub invalid_indices: HashSet<u64>,
854+
/// Tracks engine type
855+
_phantom: PhantomData<Engine>,
856+
}
857+
858+
impl<Engine> ProduceInvalidBlocks<Engine> {
859+
/// Create a new `ProduceInvalidBlocks` action
860+
pub fn new(num_blocks: u64, invalid_indices: HashSet<u64>) -> Self {
861+
Self { num_blocks, invalid_indices, _phantom: Default::default() }
862+
}
863+
864+
/// Create a new `ProduceInvalidBlocks` action with a single invalid block at the specified
865+
/// index
866+
pub fn with_invalid_at(num_blocks: u64, invalid_index: u64) -> Self {
867+
let mut invalid_indices = HashSet::new();
868+
invalid_indices.insert(invalid_index);
869+
Self::new(num_blocks, invalid_indices)
870+
}
871+
}
872+
873+
impl<Engine> Action<Engine> for ProduceInvalidBlocks<Engine>
874+
where
875+
Engine: EngineTypes + PayloadTypes,
876+
Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
877+
Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
878+
{
879+
fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
880+
Box::pin(async move {
881+
for block_index in 0..self.num_blocks {
882+
let is_invalid = self.invalid_indices.contains(&block_index);
883+
884+
if is_invalid {
885+
debug!("Producing invalid block at index {}", block_index);
886+
887+
// produce a valid block first, then corrupt it
888+
let mut sequence = Sequence::new(vec![
889+
Box::new(PickNextBlockProducer::default()),
890+
Box::new(GeneratePayloadAttributes::default()),
891+
Box::new(GenerateNextPayload::default()),
892+
]);
893+
sequence.execute(env).await?;
894+
895+
// get the latest payload and corrupt it
896+
let latest_envelope = env
897+
.latest_payload_envelope
898+
.as_ref()
899+
.ok_or_else(|| eyre::eyre!("No payload envelope available to corrupt"))?;
900+
901+
let envelope_v3: ExecutionPayloadEnvelopeV3 = latest_envelope.clone().into();
902+
let mut corrupted_payload = envelope_v3.execution_payload;
903+
904+
// corrupt the state root to make the block invalid
905+
corrupted_payload.payload_inner.payload_inner.state_root = B256::random();
906+
907+
debug!(
908+
"Corrupted state root for block {} to: {}",
909+
block_index, corrupted_payload.payload_inner.payload_inner.state_root
910+
);
911+
912+
// send the corrupted payload via newPayload
913+
let engine_client = env.node_clients[0].engine.http_client();
914+
// for simplicity, we'll use empty versioned hashes for invalid block testing
915+
let versioned_hashes = Vec::new();
916+
// use a random parent beacon block root since this is for invalid block testing
917+
let parent_beacon_block_root = B256::random();
918+
919+
let new_payload_response = EngineApiClient::<Engine>::new_payload_v3(
920+
&engine_client,
921+
corrupted_payload.clone(),
922+
versioned_hashes,
923+
parent_beacon_block_root,
924+
)
925+
.await?;
926+
927+
// expect the payload to be rejected as invalid
928+
match new_payload_response.status {
929+
PayloadStatusEnum::Invalid { validation_error } => {
930+
debug!(
931+
"Block {} correctly rejected as invalid: {:?}",
932+
block_index, validation_error
933+
);
934+
}
935+
other_status => {
936+
return Err(eyre::eyre!(
937+
"Expected block {} to be rejected as INVALID, but got: {:?}",
938+
block_index,
939+
other_status
940+
));
941+
}
942+
}
943+
944+
// update block info with the corrupted block (for potential future reference)
945+
env.current_block_info = Some(BlockInfo {
946+
hash: corrupted_payload.payload_inner.payload_inner.block_hash,
947+
number: corrupted_payload.payload_inner.payload_inner.block_number,
948+
timestamp: corrupted_payload.timestamp(),
949+
});
950+
} else {
951+
debug!("Producing valid block at index {}", block_index);
952+
953+
// produce a valid block normally
954+
let mut sequence = Sequence::new(vec![
955+
Box::new(PickNextBlockProducer::default()),
956+
Box::new(GeneratePayloadAttributes::default()),
957+
Box::new(GenerateNextPayload::default()),
958+
Box::new(BroadcastNextNewPayload::default()),
959+
Box::new(UpdateBlockInfoToLatestPayload::default()),
960+
]);
961+
sequence.execute(env).await?;
962+
}
963+
}
964+
Ok(())
965+
})
966+
}
967+
}

crates/engine/tree/src/tree/e2e_tests.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ use crate::tree::TreeConfig;
44
use eyre::Result;
55
use reth_chainspec::{ChainSpecBuilder, MAINNET};
66
use reth_e2e_test_utils::testsuite::{
7-
actions::{CaptureBlock, CreateFork, MakeCanonical, ProduceBlocks, ReorgTo},
7+
actions::{
8+
CaptureBlock, CreateFork, ExpectFcuStatus, MakeCanonical, ProduceBlocks,
9+
ProduceInvalidBlocks, ReorgTo, ValidateCanonicalTag,
10+
},
811
setup::{NetworkSetup, Setup},
912
TestBuilder,
1013
};
@@ -109,3 +112,42 @@ async fn test_engine_tree_valid_forks_with_older_canonical_head_e2e() -> Result<
109112

110113
Ok(())
111114
}
115+
116+
/// Test that verifies valid and invalid forks with an older canonical head.
117+
#[tokio::test]
118+
async fn test_engine_tree_valid_and_invalid_forks_with_older_canonical_head_e2e() -> Result<()> {
119+
reth_tracing::init_test_tracing();
120+
121+
let test = TestBuilder::new()
122+
.with_setup(default_engine_tree_setup())
123+
// create base chain with 1 block (old head)
124+
.with_action(ProduceBlocks::<EthEngineTypes>::new(1))
125+
.with_action(CaptureBlock::new("old_head"))
126+
.with_action(MakeCanonical::new())
127+
// extend base chain with 5 more blocks to establish fork point
128+
.with_action(ProduceBlocks::<EthEngineTypes>::new(5))
129+
.with_action(CaptureBlock::new("fork_point"))
130+
.with_action(MakeCanonical::new())
131+
// revert to old head to simulate older canonical head scenario
132+
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("old_head"))
133+
// create chain B (the valid chain) from fork point with 10 blocks
134+
.with_action(CreateFork::<EthEngineTypes>::new_from_tag("fork_point", 10))
135+
.with_action(CaptureBlock::new("chain_b_tip"))
136+
// make chain B canonical via FCU - this becomes the valid chain
137+
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("chain_b_tip"))
138+
// create chain A (competing chain) - first produce valid blocks, then test invalid
139+
// scenario
140+
.with_action(ReorgTo::<EthEngineTypes>::new_from_tag("fork_point"))
141+
.with_action(ProduceBlocks::<EthEngineTypes>::new(10))
142+
.with_action(CaptureBlock::new("chain_a_tip"))
143+
// test that FCU to chain A tip returns VALID status (it's a valid competing chain)
144+
.with_action(ExpectFcuStatus::valid("chain_a_tip"))
145+
// attempt to produce invalid blocks (which should be rejected)
146+
.with_action(ProduceInvalidBlocks::<EthEngineTypes>::with_invalid_at(3, 2))
147+
// chain B remains the canonical chain
148+
.with_action(ValidateCanonicalTag::new("chain_b_tip"));
149+
150+
test.run::<EthereumNode>().await?;
151+
152+
Ok(())
153+
}

0 commit comments

Comments
 (0)