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