Skip to main content

reth_e2e_test_utils/testsuite/actions/
produce_blocks.rs

1//! Block production actions for the e2e testing framework.
2
3use crate::testsuite::{
4    actions::{expect_fcu_not_syncing_or_accepted, validate_fcu_response, Action, Sequence},
5    BlockInfo, Environment,
6};
7use alloy_primitives::{Bytes, B256};
8use alloy_rpc_types_engine::{
9    payload::ExecutionPayloadEnvelopeV3, ForkchoiceState, ForkchoiceUpdated, PayloadAttributes,
10    PayloadStatus, PayloadStatusEnum,
11};
12use alloy_rpc_types_eth::{Block, Header};
13use eyre::Result;
14use futures_util::future::BoxFuture;
15use reth_node_api::{EngineTypes, PayloadKind, PayloadTypes};
16use serde_json::Value;
17use std::{collections::HashSet, marker::PhantomData, time::Duration};
18use tokio::time::sleep;
19use tracing::debug;
20
21/// Mine a single block with the given transactions and verify the block was created
22/// successfully.
23#[derive(Debug)]
24pub struct AssertMineBlock<Engine>
25where
26    Engine: PayloadTypes,
27{
28    /// The node index to mine
29    pub node_idx: usize,
30    /// Transactions to include in the block
31    pub transactions: Vec<Bytes>,
32    /// Expected block hash (optional)
33    pub expected_hash: Option<B256>,
34    /// Block's payload attributes
35    // TODO: refactor once we have actions to generate payload attributes.
36    pub payload_attributes: Engine::PayloadAttributes,
37    /// Tracks engine type
38    _phantom: PhantomData<Engine>,
39}
40
41impl<Engine> AssertMineBlock<Engine>
42where
43    Engine: PayloadTypes,
44{
45    /// Create a new `AssertMineBlock` action
46    pub fn new(
47        node_idx: usize,
48        transactions: Vec<Bytes>,
49        expected_hash: Option<B256>,
50        payload_attributes: Engine::PayloadAttributes,
51    ) -> Self {
52        Self {
53            node_idx,
54            transactions,
55            expected_hash,
56            payload_attributes,
57            _phantom: Default::default(),
58        }
59    }
60}
61
62impl<Engine> Action<Engine> for AssertMineBlock<Engine>
63where
64    Engine: EngineTypes,
65{
66    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
67        Box::pin(async move {
68            if self.node_idx >= env.node_clients.len() {
69                return Err(eyre::eyre!("Node index out of bounds: {}", self.node_idx));
70            }
71
72            let node_client = &env.node_clients[self.node_idx];
73            let rpc_client = &node_client.rpc;
74            let engine_client = node_client.engine.http_client();
75
76            // get the latest block to use as parent
77            let latest_block = rpc_client
78                .request::<_, Option<Block>>(
79                    "eth_getBlockByNumber",
80                    (alloy_eips::BlockNumberOrTag::Latest, false),
81                )
82                .await?;
83
84            let latest_block = latest_block.ok_or_else(|| eyre::eyre!("Latest block not found"))?;
85            let parent_hash = latest_block.header.hash;
86
87            debug!("Latest block hash: {parent_hash}");
88
89            // create a simple forkchoice state with the latest block as head
90            let fork_choice_state = ForkchoiceState::same_hash(parent_hash);
91
92            // Try v2 first for backwards compatibility, fall back to v3 on error.
93            match engine_client
94                .request::<_, ForkchoiceUpdated>(
95                    "engine_forkchoiceUpdatedV2",
96                    (fork_choice_state, Some(self.payload_attributes.clone())),
97                )
98                .await
99            {
100                Ok(fcu_result) => {
101                    debug!(?fcu_result, "FCU v2 result");
102                    match fcu_result.payload_status.status {
103                        PayloadStatusEnum::Valid => {
104                            if let Some(payload_id) = fcu_result.payload_id {
105                                debug!(id=%payload_id, "Got payload");
106                                let _engine_payload = engine_client
107                                    .request::<_, Value>("engine_getPayloadV2", (payload_id,))
108                                    .await?;
109                                Ok(())
110                            } else {
111                                Err(eyre::eyre!("No payload ID returned from forkchoiceUpdated"))
112                            }
113                        }
114                        _ => Err(eyre::eyre!(
115                            "Payload status not valid: {:?}",
116                            fcu_result.payload_status
117                        ))?,
118                    }
119                }
120                Err(_) => {
121                    // If v2 fails due to unsupported fork/missing fields, try v3
122                    let fcu_result = engine_client
123                        .request::<_, ForkchoiceUpdated>(
124                            "engine_forkchoiceUpdatedV3",
125                            (fork_choice_state, Some(self.payload_attributes.clone())),
126                        )
127                        .await?;
128
129                    debug!(?fcu_result, "FCU v3 result");
130                    match fcu_result.payload_status.status {
131                        PayloadStatusEnum::Valid => {
132                            if let Some(payload_id) = fcu_result.payload_id {
133                                debug!(id=%payload_id, "Got payload");
134                                let _engine_payload = engine_client
135                                    .request::<_, Value>("engine_getPayloadV3", (payload_id,))
136                                    .await?;
137                                Ok(())
138                            } else {
139                                Err(eyre::eyre!("No payload ID returned from forkchoiceUpdated"))
140                            }
141                        }
142                        _ => Err(eyre::eyre!(
143                            "Payload status not valid: {:?}",
144                            fcu_result.payload_status
145                        )),
146                    }
147                }
148            }
149        })
150    }
151}
152
153/// Pick the next block producer based on the latest block information.
154#[derive(Debug, Default)]
155pub struct PickNextBlockProducer {}
156
157impl PickNextBlockProducer {
158    /// Create a new `PickNextBlockProducer` action
159    pub const fn new() -> Self {
160        Self {}
161    }
162}
163
164impl<Engine> Action<Engine> for PickNextBlockProducer
165where
166    Engine: EngineTypes,
167{
168    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
169        Box::pin(async move {
170            let num_clients = env.node_clients.len();
171            if num_clients == 0 {
172                return Err(eyre::eyre!("No node clients available"));
173            }
174
175            let latest_info = env
176                .current_block_info()
177                .ok_or_else(|| eyre::eyre!("No latest block information available"))?;
178
179            // simple round-robin selection based on next block number
180            let next_producer_idx = ((latest_info.number + 1) % num_clients as u64) as usize;
181
182            env.last_producer_idx = Some(next_producer_idx);
183            debug!(
184                "Selected node {} as the next block producer for block {}",
185                next_producer_idx,
186                latest_info.number + 1
187            );
188
189            Ok(())
190        })
191    }
192}
193
194/// Store payload attributes for the next block.
195#[derive(Debug, Default)]
196pub struct GeneratePayloadAttributes {}
197
198impl<Engine> Action<Engine> for GeneratePayloadAttributes
199where
200    Engine: EngineTypes,
201    Engine::PayloadAttributes: From<PayloadAttributes>,
202{
203    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
204        Box::pin(async move {
205            let latest_block = env
206                .current_block_info()
207                .ok_or_else(|| eyre::eyre!("No latest block information available"))?;
208            let block_number = latest_block.number;
209            let timestamp =
210                env.active_node_state()?.latest_header_time + env.block_timestamp_increment;
211            let payload_attributes = PayloadAttributes {
212                timestamp,
213                prev_randao: B256::random(),
214                suggested_fee_recipient: alloy_primitives::Address::random(),
215                withdrawals: Some(vec![]),
216                parent_beacon_block_root: Some(B256::ZERO),
217                slot_number: None,
218                ..Default::default()
219            };
220
221            env.active_node_state_mut()?
222                .payload_attributes
223                .insert(latest_block.number + 1, payload_attributes);
224            debug!("Stored payload attributes for block {}", block_number + 1);
225            Ok(())
226        })
227    }
228}
229
230/// Action that generates the next payload
231#[derive(Debug, Default)]
232pub struct GenerateNextPayload {}
233
234impl<Engine> Action<Engine> for GenerateNextPayload
235where
236    Engine: EngineTypes,
237    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
238{
239    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
240        Box::pin(async move {
241            let latest_block = env
242                .current_block_info()
243                .ok_or_else(|| eyre::eyre!("No latest block information available"))?;
244
245            let parent_hash = latest_block.hash;
246            debug!("Latest block hash: {parent_hash}");
247
248            let fork_choice_state = ForkchoiceState::same_hash(parent_hash);
249
250            let payload_attributes = env
251                .active_node_state()?
252                .payload_attributes
253                .get(&(latest_block.number + 1))
254                .cloned()
255                .ok_or_else(|| eyre::eyre!("No payload attributes found for next block"))?;
256
257            let producer_idx =
258                env.last_producer_idx.ok_or_else(|| eyre::eyre!("No block producer selected"))?;
259
260            let fcu_result = env.node_clients[producer_idx]
261                .engine
262                .http_client()
263                .request::<_, ForkchoiceUpdated>(
264                    "engine_forkchoiceUpdatedV3",
265                    (fork_choice_state, Some(payload_attributes.clone())),
266                )
267                .await?;
268
269            debug!("FCU result: {:?}", fcu_result);
270
271            // validate the FCU status before proceeding
272            // Note: In the context of GenerateNextPayload, Syncing usually means the engine
273            // doesn't have the requested head block, which should be an error
274            expect_fcu_not_syncing_or_accepted(&fcu_result, "GenerateNextPayload")?;
275
276            let payload_id = if let Some(payload_id) = fcu_result.payload_id {
277                debug!("Received new payload ID: {:?}", payload_id);
278                payload_id
279            } else {
280                debug!("No payload ID returned, generating fresh payload attributes for forking");
281
282                let fresh_payload_attributes = PayloadAttributes {
283                    timestamp: env.active_node_state()?.latest_header_time +
284                        env.block_timestamp_increment,
285                    prev_randao: B256::random(),
286                    suggested_fee_recipient: alloy_primitives::Address::random(),
287                    withdrawals: Some(vec![]),
288                    parent_beacon_block_root: Some(B256::ZERO),
289                    slot_number: None,
290                    ..Default::default()
291                };
292
293                let fresh_fcu_result = env.node_clients[producer_idx]
294                    .engine
295                    .http_client()
296                    .request::<_, ForkchoiceUpdated>(
297                        "engine_forkchoiceUpdatedV3",
298                        (fork_choice_state, Some(fresh_payload_attributes.clone())),
299                    )
300                    .await?;
301
302                debug!("Fresh FCU result: {:?}", fresh_fcu_result);
303
304                // validate the fresh FCU status
305                expect_fcu_not_syncing_or_accepted(
306                    &fresh_fcu_result,
307                    "GenerateNextPayload (fresh)",
308                )?;
309
310                if let Some(payload_id) = fresh_fcu_result.payload_id {
311                    payload_id
312                } else {
313                    debug!("Engine considers the fork base already canonical, skipping payload generation");
314                    return Ok(());
315                }
316            };
317
318            env.active_node_state_mut()?.next_payload_id = Some(payload_id);
319
320            if let Some(builder) = &env.node_clients[producer_idx].payload_builder {
321                // Wait for the pending build rather than racing it with an empty fallback payload.
322                tokio::time::timeout(
323                    Duration::from_secs(30),
324                    builder.resolve_kind(payload_id, PayloadKind::WaitForPending),
325                )
326                .await?
327                .ok_or_else(|| eyre::eyre!("Unknown payload {payload_id}"))??;
328            } else {
329                // RPC-only clients do not expose the local payload builder.
330                sleep(Duration::from_secs(1)).await;
331            }
332
333            let built_payload_envelope =
334                serde_json::from_value::<Engine::ExecutionPayloadEnvelopeV3>(
335                    env.node_clients[producer_idx]
336                        .engine
337                        .http_client()
338                        .request::<_, Value>("engine_getPayloadV3", (payload_id,))
339                        .await?,
340                )?;
341
342            // Store the payload attributes that were used to generate this payload
343            let built_payload = payload_attributes.clone();
344            env.active_node_state_mut()?
345                .payload_id_history
346                .insert(latest_block.number + 1, payload_id);
347            env.active_node_state_mut()?.latest_payload_built = Some(built_payload);
348            env.active_node_state_mut()?.latest_payload_envelope = Some(built_payload_envelope);
349
350            Ok(())
351        })
352    }
353}
354
355/// Action that broadcasts the latest fork choice state to all clients
356#[derive(Debug, Default)]
357pub struct BroadcastLatestForkchoice {}
358
359impl<Engine> Action<Engine> for BroadcastLatestForkchoice
360where
361    Engine: EngineTypes,
362    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
363    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
364{
365    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
366        Box::pin(async move {
367            if env.node_clients.is_empty() {
368                return Err(eyre::eyre!("No node clients available"));
369            }
370
371            // use the hash of the newly executed payload if available
372            let head_hash = if let Some(payload_envelope) =
373                &env.active_node_state()?.latest_payload_envelope
374            {
375                let execution_payload_envelope: ExecutionPayloadEnvelopeV3 =
376                    payload_envelope.clone().into();
377                let new_block_hash = execution_payload_envelope
378                    .execution_payload
379                    .payload_inner
380                    .payload_inner
381                    .block_hash;
382                debug!("Using newly executed block hash as head: {new_block_hash}");
383                new_block_hash
384            } else {
385                // fallback to RPC query
386                let rpc_client = &env.node_clients[0].rpc;
387                let current_head_block = rpc_client
388                    .request::<_, Option<Block>>(
389                        "eth_getBlockByNumber",
390                        (alloy_eips::BlockNumberOrTag::Latest, false),
391                    )
392                    .await?
393                    .ok_or_else(|| eyre::eyre!("No latest block found from RPC"))?;
394                debug!("Using RPC latest block hash as head: {}", current_head_block.header.hash);
395                current_head_block.header.hash
396            };
397
398            broadcast_forkchoice(env, head_hash).await
399        })
400    }
401}
402
403/// Action that syncs environment state with the node's canonical chain via RPC.
404///
405/// This queries the latest canonical block from the node and updates the environment
406/// to match. Typically used after forkchoice operations to ensure the environment
407/// is in sync with the node's view of the canonical chain.
408#[derive(Debug, Default)]
409pub struct UpdateBlockInfo {}
410
411impl<Engine> Action<Engine> for UpdateBlockInfo
412where
413    Engine: EngineTypes,
414{
415    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
416        Box::pin(async move {
417            // get the latest block from the first client to update environment state
418            let rpc_client = &env.node_clients[0].rpc;
419            let latest_block = rpc_client
420                .request::<_, Option<Block>>(
421                    "eth_getBlockByNumber",
422                    (alloy_eips::BlockNumberOrTag::Latest, false),
423                )
424                .await?
425                .ok_or_else(|| eyre::eyre!("No latest block found from RPC"))?;
426
427            // update environment with the new block information
428            env.set_current_block_info(BlockInfo {
429                hash: latest_block.header.hash,
430                number: latest_block.header.number,
431                timestamp: latest_block.header.timestamp,
432            })?;
433
434            env.active_node_state_mut()?.latest_header_time = latest_block.header.timestamp;
435            env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash =
436                latest_block.header.hash;
437
438            debug!(
439                "Updated environment to block {} (hash: {})",
440                latest_block.header.number, latest_block.header.hash
441            );
442
443            Ok(())
444        })
445    }
446}
447
448/// Action that updates environment state using the locally produced payload.
449///
450/// This uses the execution payload stored in the environment rather than querying RPC,
451/// making it more efficient and reliable during block production. Preferred over
452/// `UpdateBlockInfo` when we have just produced a block and have the payload available.
453#[derive(Debug, Default)]
454pub struct UpdateBlockInfoToLatestPayload {}
455
456impl<Engine> Action<Engine> for UpdateBlockInfoToLatestPayload
457where
458    Engine: EngineTypes,
459    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
460{
461    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
462        Box::pin(async move {
463            let payload_envelope = env
464                .active_node_state()?
465                .latest_payload_envelope
466                .as_ref()
467                .ok_or_else(|| eyre::eyre!("No execution payload envelope available"))?;
468
469            let execution_payload_envelope: ExecutionPayloadEnvelopeV3 =
470                payload_envelope.clone().into();
471            let execution_payload = execution_payload_envelope.execution_payload;
472
473            let block_hash = execution_payload.payload_inner.payload_inner.block_hash;
474            let block_number = execution_payload.payload_inner.payload_inner.block_number;
475            let block_timestamp = execution_payload.payload_inner.payload_inner.timestamp;
476
477            // update environment with the new block information from the payload
478            env.set_current_block_info(BlockInfo {
479                hash: block_hash,
480                number: block_number,
481                timestamp: block_timestamp,
482            })?;
483
484            env.active_node_state_mut()?.latest_header_time = block_timestamp;
485            env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash = block_hash;
486
487            debug!(
488                "Updated environment to newly produced block {} (hash: {})",
489                block_number, block_hash
490            );
491
492            Ok(())
493        })
494    }
495}
496
497/// Action that checks whether the broadcasted new payload has been accepted
498#[derive(Debug, Default)]
499pub struct CheckPayloadAccepted {}
500
501impl<Engine> Action<Engine> for CheckPayloadAccepted
502where
503    Engine: EngineTypes,
504    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
505{
506    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
507        Box::pin(async move {
508            let mut accepted_check: bool = false;
509
510            let latest_block = env
511                .current_block_info()
512                .ok_or_else(|| eyre::eyre!("No latest block information available"))?;
513
514            let payload_id = *env
515                .active_node_state()?
516                .payload_id_history
517                .get(&(latest_block.number + 1))
518                .ok_or_else(|| eyre::eyre!("Cannot find payload_id"))?;
519
520            let node_clients = env.node_clients.clone();
521            for (idx, client) in node_clients.iter().enumerate() {
522                let rpc_client = &client.rpc;
523
524                // get the last header by number using latest_head_number
525                let rpc_latest_header = rpc_client
526                    .request::<_, Option<Header>>(
527                        "eth_getHeaderByNumber",
528                        (alloy_eips::BlockNumberOrTag::Latest,),
529                    )
530                    .await?
531                    .ok_or_else(|| eyre::eyre!("No latest header found from rpc"))?;
532
533                // perform several checks
534                let next_new_payload = env
535                    .active_node_state()?
536                    .latest_payload_built
537                    .as_ref()
538                    .ok_or_else(|| eyre::eyre!("No next built payload found"))?;
539
540                let execution_payload_envelope = client
541                    .engine
542                    .http_client()
543                    .request::<_, ExecutionPayloadEnvelopeV3>("engine_getPayloadV3", (payload_id,))
544                    .await?;
545                let new_payload_block_hash = execution_payload_envelope
546                    .execution_payload
547                    .payload_inner
548                    .payload_inner
549                    .block_hash;
550
551                if rpc_latest_header.hash != new_payload_block_hash {
552                    debug!(
553                        "Client {}: The hash is not matched: {:?} {:?}",
554                        idx, rpc_latest_header.hash, new_payload_block_hash
555                    );
556                    continue;
557                }
558
559                if !rpc_latest_header.inner.difficulty.is_zero() {
560                    debug!(
561                        "Client {}: difficulty != 0: {:?}",
562                        idx, rpc_latest_header.inner.difficulty
563                    );
564                    continue;
565                }
566
567                if rpc_latest_header.inner.mix_hash != next_new_payload.prev_randao {
568                    debug!(
569                        "Client {}: The mix_hash and prev_randao is not same: {:?} {:?}",
570                        idx, rpc_latest_header.inner.mix_hash, next_new_payload.prev_randao
571                    );
572                    continue;
573                }
574
575                let extra_len = rpc_latest_header.inner.extra_data.len();
576                if extra_len <= 32 {
577                    debug!("Client {}: extra_len is fewer than 32. extra_len: {}", idx, extra_len);
578                    continue;
579                }
580
581                // at least one client passes all the check, save the header in Env
582                if !accepted_check {
583                    accepted_check = true;
584                    // save the current block info in Env
585                    env.set_current_block_info(BlockInfo {
586                        hash: rpc_latest_header.hash,
587                        number: rpc_latest_header.inner.number,
588                        timestamp: rpc_latest_header.inner.timestamp,
589                    })?;
590
591                    // align latest header time and forkchoice state with the accepted canonical
592                    // head
593                    env.active_node_state_mut()?.latest_header_time =
594                        rpc_latest_header.inner.timestamp;
595                    env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash =
596                        rpc_latest_header.hash;
597                }
598            }
599
600            if accepted_check {
601                Ok(())
602            } else {
603                Err(eyre::eyre!("No clients passed payload acceptance checks"))
604            }
605        })
606    }
607}
608
609/// Action that broadcasts the next new payload
610#[derive(Debug, Default)]
611pub struct BroadcastNextNewPayload {
612    /// If true, only send to the active node. If false, broadcast to all nodes.
613    active_node_only: bool,
614}
615
616impl BroadcastNextNewPayload {
617    /// Create a new `BroadcastNextNewPayload` action that only sends to the active node
618    pub const fn with_active_node() -> Self {
619        Self { active_node_only: true }
620    }
621}
622
623impl<Engine> Action<Engine> for BroadcastNextNewPayload
624where
625    Engine: EngineTypes,
626    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
627    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
628{
629    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
630        Box::pin(async move {
631            // Get the next new payload to broadcast
632            let next_new_payload = env
633                .active_node_state()?
634                .latest_payload_built
635                .as_ref()
636                .ok_or_else(|| eyre::eyre!("No next built payload found"))?
637                .clone();
638            let parent_beacon_block_root = next_new_payload
639                .parent_beacon_block_root
640                .ok_or_else(|| eyre::eyre!("No parent beacon block root for next new payload"))?;
641
642            let payload_envelope = env
643                .active_node_state()?
644                .latest_payload_envelope
645                .as_ref()
646                .ok_or_else(|| eyre::eyre!("No execution payload envelope available"))?
647                .clone();
648
649            let execution_payload_envelope: ExecutionPayloadEnvelopeV3 = payload_envelope.into();
650            let execution_payload = execution_payload_envelope.execution_payload;
651
652            if self.active_node_only {
653                // Send only to the active node
654                let active_idx = env.active_node_idx;
655                let engine = env.node_clients[active_idx].engine.http_client();
656
657                let result = engine
658                    .request::<_, PayloadStatus>(
659                        "engine_newPayloadV3",
660                        (execution_payload.clone(), Vec::<B256>::new(), parent_beacon_block_root),
661                    )
662                    .await?;
663
664                debug!("Active node {}: new_payload status: {:?}", active_idx, result.status);
665
666                // Validate the response
667                match result.status {
668                    PayloadStatusEnum::Valid => {
669                        env.active_node_state_mut()?.latest_payload_executed =
670                            Some(next_new_payload);
671                        Ok(())
672                    }
673                    other => Err(eyre::eyre!(
674                        "Active node {}: Unexpected payload status: {:?}",
675                        active_idx,
676                        other
677                    )),
678                }
679            } else {
680                // Loop through all clients and broadcast the next new payload
681                let mut broadcast_results = Vec::new();
682                let mut first_valid_seen = false;
683
684                for (idx, client) in env.node_clients.iter().enumerate() {
685                    let engine = client.engine.http_client();
686
687                    // Broadcast the execution payload
688                    let result = engine
689                        .request::<_, PayloadStatus>(
690                            "engine_newPayloadV3",
691                            (
692                                execution_payload.clone(),
693                                Vec::<B256>::new(),
694                                parent_beacon_block_root,
695                            ),
696                        )
697                        .await?;
698
699                    broadcast_results.push((idx, result.status.clone()));
700                    debug!("Node {}: new_payload broadcast status: {:?}", idx, result.status);
701
702                    // Check if this node accepted the payload
703                    if result.is_valid() && !first_valid_seen {
704                        first_valid_seen = true;
705                    } else if let PayloadStatusEnum::Invalid { validation_error } = result.status {
706                        debug!(
707                            "Node {}: Invalid payload status returned from broadcast: {:?}",
708                            idx, validation_error
709                        );
710                    }
711                }
712
713                // Update the executed payload state after broadcasting to all nodes
714                if first_valid_seen {
715                    env.active_node_state_mut()?.latest_payload_executed = Some(next_new_payload);
716                }
717
718                // Check if at least one node accepted the payload
719                let any_valid = broadcast_results.iter().any(|(_, status)| status.is_valid());
720                if !any_valid {
721                    return Err(eyre::eyre!(
722                        "Failed to successfully broadcast payload to any client"
723                    ));
724                }
725
726                debug!("Broadcast complete. Results: {:?}", broadcast_results);
727
728                Ok(())
729            }
730        })
731    }
732}
733
734/// Action that produces a sequence of blocks using the available clients
735#[derive(Debug)]
736pub struct ProduceBlocks<Engine> {
737    /// Number of blocks to produce
738    pub num_blocks: u64,
739    /// Tracks engine type
740    _phantom: PhantomData<Engine>,
741}
742
743impl<Engine> ProduceBlocks<Engine> {
744    /// Create a new `ProduceBlocks` action
745    pub fn new(num_blocks: u64) -> Self {
746        Self { num_blocks, _phantom: Default::default() }
747    }
748}
749
750impl<Engine> Default for ProduceBlocks<Engine> {
751    fn default() -> Self {
752        Self::new(0)
753    }
754}
755
756impl<Engine> Action<Engine> for ProduceBlocks<Engine>
757where
758    Engine: EngineTypes,
759    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
760    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
761{
762    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
763        Box::pin(async move {
764            for _ in 0..self.num_blocks {
765                // create a fresh sequence for each block to avoid state pollution
766                // Note: This produces blocks but does NOT make them canonical
767                // Use MakeCanonical action explicitly if canonicalization is needed
768                let mut sequence = Sequence::new(vec![
769                    Box::new(PickNextBlockProducer::default()),
770                    Box::new(GeneratePayloadAttributes::default()),
771                    Box::new(GenerateNextPayload::default()),
772                    Box::new(BroadcastNextNewPayload::default()),
773                    Box::new(UpdateBlockInfoToLatestPayload::default()),
774                ]);
775                sequence.execute(env).await?;
776            }
777            Ok(())
778        })
779    }
780}
781
782/// Action to test forkchoice update to a tagged block with expected status
783#[derive(Debug)]
784pub struct TestFcuToTag {
785    /// Tag name of the target block
786    pub tag: String,
787    /// Expected payload status
788    pub expected_status: PayloadStatusEnum,
789}
790
791impl TestFcuToTag {
792    /// Create a new `TestFcuToTag` action
793    pub fn new(tag: impl Into<String>, expected_status: PayloadStatusEnum) -> Self {
794        Self { tag: tag.into(), expected_status }
795    }
796}
797
798impl<Engine> Action<Engine> for TestFcuToTag
799where
800    Engine: EngineTypes,
801{
802    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
803        Box::pin(async move {
804            // get the target block from the registry
805            let (target_block, _node_idx) = env
806                .block_registry
807                .get(&self.tag)
808                .copied()
809                .ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?;
810
811            let engine_client = env.node_clients[0].engine.http_client();
812            let fcu_state = ForkchoiceState::same_hash(target_block.hash);
813
814            let fcu_response = engine_client
815                .request::<_, ForkchoiceUpdated>(
816                    "engine_forkchoiceUpdatedV2",
817                    (fcu_state, None::<PayloadAttributes>),
818                )
819                .await?;
820
821            // validate the response matches expected status
822            match (&fcu_response.payload_status.status, &self.expected_status) {
823                (PayloadStatusEnum::Valid, PayloadStatusEnum::Valid) => {
824                    debug!("FCU to '{}' returned VALID as expected", self.tag);
825                }
826                (PayloadStatusEnum::Invalid { .. }, PayloadStatusEnum::Invalid { .. }) => {
827                    debug!("FCU to '{}' returned INVALID as expected", self.tag);
828                }
829                (PayloadStatusEnum::Syncing, PayloadStatusEnum::Syncing) => {
830                    debug!("FCU to '{}' returned SYNCING as expected", self.tag);
831                }
832                (PayloadStatusEnum::Accepted, PayloadStatusEnum::Accepted) => {
833                    debug!("FCU to '{}' returned ACCEPTED as expected", self.tag);
834                }
835                (actual, expected) => {
836                    return Err(eyre::eyre!(
837                        "FCU to '{}': expected status {:?}, but got {:?}",
838                        self.tag,
839                        expected,
840                        actual
841                    ));
842                }
843            }
844
845            Ok(())
846        })
847    }
848}
849
850/// Action to expect a specific FCU status when targeting a tagged block
851#[derive(Debug)]
852pub struct ExpectFcuStatus {
853    /// Tag name of the target block
854    pub target_tag: String,
855    /// Expected payload status
856    pub expected_status: PayloadStatusEnum,
857}
858
859impl ExpectFcuStatus {
860    /// Create a new `ExpectFcuStatus` action expecting VALID status
861    pub fn valid(target_tag: impl Into<String>) -> Self {
862        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Valid }
863    }
864
865    /// Create a new `ExpectFcuStatus` action expecting INVALID status
866    pub fn invalid(target_tag: impl Into<String>) -> Self {
867        Self {
868            target_tag: target_tag.into(),
869            expected_status: PayloadStatusEnum::Invalid {
870                validation_error: "corrupted block".to_string(),
871            },
872        }
873    }
874
875    /// Create a new `ExpectFcuStatus` action expecting SYNCING status
876    pub fn syncing(target_tag: impl Into<String>) -> Self {
877        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Syncing }
878    }
879
880    /// Create a new `ExpectFcuStatus` action expecting ACCEPTED status
881    pub fn accepted(target_tag: impl Into<String>) -> Self {
882        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Accepted }
883    }
884}
885
886impl<Engine> Action<Engine> for ExpectFcuStatus
887where
888    Engine: EngineTypes,
889{
890    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
891        Box::pin(async move {
892            let mut test_fcu = TestFcuToTag::new(&self.target_tag, self.expected_status.clone());
893            test_fcu.execute(env).await
894        })
895    }
896}
897
898/// Action to validate that a tagged block remains canonical by performing FCU to it
899#[derive(Debug)]
900pub struct ValidateCanonicalTag {
901    /// Tag name of the block to validate as canonical
902    pub tag: String,
903}
904
905impl ValidateCanonicalTag {
906    /// Create a new `ValidateCanonicalTag` action
907    pub fn new(tag: impl Into<String>) -> Self {
908        Self { tag: tag.into() }
909    }
910}
911
912impl<Engine> Action<Engine> for ValidateCanonicalTag
913where
914    Engine: EngineTypes,
915{
916    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
917        Box::pin(async move {
918            let mut expect_valid = ExpectFcuStatus::valid(&self.tag);
919            expect_valid.execute(env).await?;
920
921            debug!("Successfully validated that '{}' remains canonical", self.tag);
922            Ok(())
923        })
924    }
925}
926
927/// Action that produces blocks locally without broadcasting to other nodes
928/// This sends the payload only to the active node to ensure it's available locally
929#[derive(Debug)]
930pub struct ProduceBlocksLocally<Engine> {
931    /// Number of blocks to produce
932    pub num_blocks: u64,
933    /// Tracks engine type
934    _phantom: PhantomData<Engine>,
935}
936
937impl<Engine> ProduceBlocksLocally<Engine> {
938    /// Create a new `ProduceBlocksLocally` action
939    pub fn new(num_blocks: u64) -> Self {
940        Self { num_blocks, _phantom: Default::default() }
941    }
942}
943
944impl<Engine> Default for ProduceBlocksLocally<Engine> {
945    fn default() -> Self {
946        Self::new(0)
947    }
948}
949
950impl<Engine> Action<Engine> for ProduceBlocksLocally<Engine>
951where
952    Engine: EngineTypes,
953    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
954    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
955{
956    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
957        Box::pin(async move {
958            // Remember the active node to ensure all blocks are produced on the same node
959            let producer_idx = env.active_node_idx;
960
961            for _ in 0..self.num_blocks {
962                // Ensure we always use the same producer
963                env.last_producer_idx = Some(producer_idx);
964
965                // create a sequence that produces blocks and sends only to active node
966                let mut sequence = Sequence::new(vec![
967                    // Skip PickNextBlockProducer to maintain the same producer
968                    Box::new(GeneratePayloadAttributes::default()),
969                    Box::new(GenerateNextPayload::default()),
970                    // Send payload only to the active node to make it available
971                    Box::new(BroadcastNextNewPayload::with_active_node()),
972                    Box::new(UpdateBlockInfoToLatestPayload::default()),
973                ]);
974                sequence.execute(env).await?;
975            }
976            Ok(())
977        })
978    }
979}
980
981/// Action that produces a sequence of blocks where some blocks are intentionally invalid
982#[derive(Debug)]
983pub struct ProduceInvalidBlocks<Engine> {
984    /// Number of blocks to produce
985    pub num_blocks: u64,
986    /// Set of indices (0-based) where blocks should be made invalid
987    pub invalid_indices: HashSet<u64>,
988    /// Tracks engine type
989    _phantom: PhantomData<Engine>,
990}
991
992impl<Engine> ProduceInvalidBlocks<Engine> {
993    /// Create a new `ProduceInvalidBlocks` action
994    pub fn new(num_blocks: u64, invalid_indices: HashSet<u64>) -> Self {
995        Self { num_blocks, invalid_indices, _phantom: Default::default() }
996    }
997
998    /// Create a new `ProduceInvalidBlocks` action with a single invalid block at the specified
999    /// index
1000    pub fn with_invalid_at(num_blocks: u64, invalid_index: u64) -> Self {
1001        let mut invalid_indices = HashSet::new();
1002        invalid_indices.insert(invalid_index);
1003        Self::new(num_blocks, invalid_indices)
1004    }
1005}
1006
1007impl<Engine> Action<Engine> for ProduceInvalidBlocks<Engine>
1008where
1009    Engine: EngineTypes,
1010    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
1011    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
1012{
1013    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
1014        Box::pin(async move {
1015            for block_index in 0..self.num_blocks {
1016                let is_invalid = self.invalid_indices.contains(&block_index);
1017
1018                if is_invalid {
1019                    debug!("Producing invalid block at index {}", block_index);
1020
1021                    // produce a valid block first, then corrupt it
1022                    let mut sequence = Sequence::new(vec![
1023                        Box::new(PickNextBlockProducer::default()),
1024                        Box::new(GeneratePayloadAttributes::default()),
1025                        Box::new(GenerateNextPayload::default()),
1026                    ]);
1027                    sequence.execute(env).await?;
1028
1029                    // get the latest payload and corrupt it
1030                    let latest_envelope =
1031                        env.active_node_state()?.latest_payload_envelope.as_ref().ok_or_else(
1032                            || eyre::eyre!("No payload envelope available to corrupt"),
1033                        )?;
1034
1035                    let envelope_v3: ExecutionPayloadEnvelopeV3 = latest_envelope.clone().into();
1036                    let mut corrupted_payload = envelope_v3.execution_payload;
1037
1038                    // corrupt the state root to make the block invalid
1039                    corrupted_payload.payload_inner.payload_inner.state_root = B256::random();
1040
1041                    debug!(
1042                        "Corrupted state root for block {} to: {}",
1043                        block_index, corrupted_payload.payload_inner.payload_inner.state_root
1044                    );
1045
1046                    // send the corrupted payload via newPayload
1047                    let engine_client = env.node_clients[0].engine.http_client();
1048                    // for simplicity, we'll use empty versioned hashes for invalid block testing
1049                    let versioned_hashes = Vec::<B256>::new();
1050                    // use a random parent beacon block root since this is for invalid block testing
1051                    let parent_beacon_block_root = B256::random();
1052
1053                    let new_payload_response = engine_client
1054                        .request::<_, PayloadStatus>(
1055                            "engine_newPayloadV3",
1056                            (corrupted_payload.clone(), versioned_hashes, parent_beacon_block_root),
1057                        )
1058                        .await?;
1059
1060                    // expect the payload to be rejected as invalid
1061                    match new_payload_response.status {
1062                        PayloadStatusEnum::Invalid { validation_error } => {
1063                            debug!(
1064                                "Block {} correctly rejected as invalid: {:?}",
1065                                block_index, validation_error
1066                            );
1067                        }
1068                        other_status => {
1069                            return Err(eyre::eyre!(
1070                                "Expected block {} to be rejected as INVALID, but got: {:?}",
1071                                block_index,
1072                                other_status
1073                            ));
1074                        }
1075                    }
1076
1077                    // update block info with the corrupted block (for potential future reference)
1078                    env.set_current_block_info(BlockInfo {
1079                        hash: corrupted_payload.payload_inner.payload_inner.block_hash,
1080                        number: corrupted_payload.payload_inner.payload_inner.block_number,
1081                        timestamp: corrupted_payload.timestamp(),
1082                    })?;
1083                } else {
1084                    debug!("Producing valid block at index {}", block_index);
1085
1086                    // produce a valid block normally
1087                    let mut sequence = Sequence::new(vec![
1088                        Box::new(PickNextBlockProducer::default()),
1089                        Box::new(GeneratePayloadAttributes::default()),
1090                        Box::new(GenerateNextPayload::default()),
1091                        Box::new(BroadcastNextNewPayload::default()),
1092                        Box::new(UpdateBlockInfoToLatestPayload::default()),
1093                    ]);
1094                    sequence.execute(env).await?;
1095                }
1096            }
1097            Ok(())
1098        })
1099    }
1100}
1101
1102/// Broadcasts a forkchoice update to all clients, using `head_hash` as the head and safe block.
1103pub(super) async fn broadcast_forkchoice<Engine>(
1104    env: &Environment<Engine>,
1105    head_hash: B256,
1106) -> Result<()>
1107where
1108    Engine: EngineTypes,
1109{
1110    let fork_choice_state = ForkchoiceState {
1111        head_block_hash: head_hash,
1112        safe_block_hash: head_hash,
1113        // Making a block canonical does not imply finality: tests advance the finalized block
1114        // explicitly via `FinalizeBlock`, and a finalized tip would reject any later forkchoice
1115        // update below it as a too deep reorg.
1116        finalized_block_hash: B256::ZERO,
1117    };
1118    debug!(
1119        "Broadcasting forkchoice update to {} clients. Head: {:?}",
1120        env.node_clients.len(),
1121        fork_choice_state.head_block_hash
1122    );
1123
1124    for (idx, client) in env.node_clients.iter().enumerate() {
1125        match client
1126            .engine
1127            .http_client()
1128            .request::<_, ForkchoiceUpdated>(
1129                "engine_forkchoiceUpdatedV3",
1130                (fork_choice_state, None::<PayloadAttributes>),
1131            )
1132            .await
1133        {
1134            Ok(resp) => {
1135                debug!(
1136                    "Client {}: Forkchoice update status: {:?}",
1137                    idx, resp.payload_status.status
1138                );
1139                // validate that the forkchoice update was accepted
1140                validate_fcu_response(&resp, &format!("Client {idx}"))?;
1141            }
1142            Err(err) => {
1143                return Err(eyre::eyre!(
1144                    "Client {}: Failed to broadcast forkchoice: {:?}",
1145                    idx,
1146                    err
1147                ));
1148            }
1149        }
1150    }
1151    debug!("Forkchoice update broadcasted successfully");
1152    Ok(())
1153}