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, PayloadKind, 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            if let Some(builder) = &env.node_clients[producer_idx].payload_builder {
334                // Wait for the pending build rather than racing it with an empty fallback payload.
335                tokio::time::timeout(
336                    Duration::from_secs(30),
337                    builder.resolve_kind(payload_id, PayloadKind::WaitForPending),
338                )
339                .await?
340                .ok_or_else(|| eyre::eyre!("Unknown payload {payload_id}"))??;
341            } else {
342                // RPC-only clients do not expose the local payload builder.
343                sleep(Duration::from_secs(1)).await;
344            }
345
346            let built_payload_envelope = EngineApiClient::<Engine>::get_payload_v3(
347                &env.node_clients[producer_idx].engine.http_client(),
348                payload_id,
349            )
350            .await?;
351
352            // Store the payload attributes that were used to generate this payload
353            let built_payload = payload_attributes.clone();
354            env.active_node_state_mut()?
355                .payload_id_history
356                .insert(latest_block.number + 1, payload_id);
357            env.active_node_state_mut()?.latest_payload_built = Some(built_payload);
358            env.active_node_state_mut()?.latest_payload_envelope = Some(built_payload_envelope);
359
360            Ok(())
361        })
362    }
363}
364
365/// Action that broadcasts the latest fork choice state to all clients
366#[derive(Debug, Default)]
367pub struct BroadcastLatestForkchoice {}
368
369impl<Engine> Action<Engine> for BroadcastLatestForkchoice
370where
371    Engine: EngineTypes + PayloadTypes,
372    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
373    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
374{
375    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
376        Box::pin(async move {
377            if env.node_clients.is_empty() {
378                return Err(eyre::eyre!("No node clients available"));
379            }
380
381            // use the hash of the newly executed payload if available
382            let head_hash = if let Some(payload_envelope) =
383                &env.active_node_state()?.latest_payload_envelope
384            {
385                let execution_payload_envelope: ExecutionPayloadEnvelopeV3 =
386                    payload_envelope.clone().into();
387                let new_block_hash = execution_payload_envelope
388                    .execution_payload
389                    .payload_inner
390                    .payload_inner
391                    .block_hash;
392                debug!("Using newly executed block hash as head: {new_block_hash}");
393                new_block_hash
394            } else {
395                // fallback to RPC query
396                let rpc_client = &env.node_clients[0].rpc;
397                let current_head_block = EthApiClient::<
398                    TransactionRequest,
399                    Transaction,
400                    Block,
401                    Receipt,
402                    Header,
403                    TransactionSigned,
404                >::block_by_number(
405                    rpc_client, alloy_eips::BlockNumberOrTag::Latest, false
406                )
407                .await?
408                .ok_or_else(|| eyre::eyre!("No latest block found from RPC"))?;
409                debug!("Using RPC latest block hash as head: {}", current_head_block.header.hash);
410                current_head_block.header.hash
411            };
412
413            let fork_choice_state = ForkchoiceState {
414                head_block_hash: head_hash,
415                safe_block_hash: head_hash,
416                // Making a block canonical does not imply finality: tests advance the finalized
417                // block explicitly via `FinalizeBlock`, and a finalized tip would reject any
418                // later forkchoice update below it as a too deep reorg.
419                finalized_block_hash: B256::ZERO,
420            };
421            debug!(
422                "Broadcasting forkchoice update to {} clients. Head: {:?}",
423                env.node_clients.len(),
424                fork_choice_state.head_block_hash
425            );
426
427            for (idx, client) in env.node_clients.iter().enumerate() {
428                match EngineApiClient::<Engine>::fork_choice_updated_v3(
429                    &client.engine.http_client(),
430                    fork_choice_state,
431                    None,
432                )
433                .await
434                {
435                    Ok(resp) => {
436                        debug!(
437                            "Client {}: Forkchoice update status: {:?}",
438                            idx, resp.payload_status.status
439                        );
440                        // validate that the forkchoice update was accepted
441                        validate_fcu_response(&resp, &format!("Client {idx}"))?;
442                    }
443                    Err(err) => {
444                        return Err(eyre::eyre!(
445                            "Client {}: Failed to broadcast forkchoice: {:?}",
446                            idx,
447                            err
448                        ));
449                    }
450                }
451            }
452            debug!("Forkchoice update broadcasted successfully");
453            Ok(())
454        })
455    }
456}
457
458/// Action that syncs environment state with the node's canonical chain via RPC.
459///
460/// This queries the latest canonical block from the node and updates the environment
461/// to match. Typically used after forkchoice operations to ensure the environment
462/// is in sync with the node's view of the canonical chain.
463#[derive(Debug, Default)]
464pub struct UpdateBlockInfo {}
465
466impl<Engine> Action<Engine> for UpdateBlockInfo
467where
468    Engine: EngineTypes,
469{
470    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
471        Box::pin(async move {
472            // get the latest block from the first client to update environment state
473            let rpc_client = &env.node_clients[0].rpc;
474            let latest_block = EthApiClient::<
475                TransactionRequest,
476                Transaction,
477                Block,
478                Receipt,
479                Header,
480                TransactionSigned,
481            >::block_by_number(
482                rpc_client, alloy_eips::BlockNumberOrTag::Latest, false
483            )
484            .await?
485            .ok_or_else(|| eyre::eyre!("No latest block found from RPC"))?;
486
487            // update environment with the new block information
488            env.set_current_block_info(BlockInfo {
489                hash: latest_block.header.hash,
490                number: latest_block.header.number,
491                timestamp: latest_block.header.timestamp,
492            })?;
493
494            env.active_node_state_mut()?.latest_header_time = latest_block.header.timestamp;
495            env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash =
496                latest_block.header.hash;
497
498            debug!(
499                "Updated environment to block {} (hash: {})",
500                latest_block.header.number, latest_block.header.hash
501            );
502
503            Ok(())
504        })
505    }
506}
507
508/// Action that updates environment state using the locally produced payload.
509///
510/// This uses the execution payload stored in the environment rather than querying RPC,
511/// making it more efficient and reliable during block production. Preferred over
512/// `UpdateBlockInfo` when we have just produced a block and have the payload available.
513#[derive(Debug, Default)]
514pub struct UpdateBlockInfoToLatestPayload {}
515
516impl<Engine> Action<Engine> for UpdateBlockInfoToLatestPayload
517where
518    Engine: EngineTypes + PayloadTypes,
519    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
520{
521    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
522        Box::pin(async move {
523            let payload_envelope = env
524                .active_node_state()?
525                .latest_payload_envelope
526                .as_ref()
527                .ok_or_else(|| eyre::eyre!("No execution payload envelope available"))?;
528
529            let execution_payload_envelope: ExecutionPayloadEnvelopeV3 =
530                payload_envelope.clone().into();
531            let execution_payload = execution_payload_envelope.execution_payload;
532
533            let block_hash = execution_payload.payload_inner.payload_inner.block_hash;
534            let block_number = execution_payload.payload_inner.payload_inner.block_number;
535            let block_timestamp = execution_payload.payload_inner.payload_inner.timestamp;
536
537            // update environment with the new block information from the payload
538            env.set_current_block_info(BlockInfo {
539                hash: block_hash,
540                number: block_number,
541                timestamp: block_timestamp,
542            })?;
543
544            env.active_node_state_mut()?.latest_header_time = block_timestamp;
545            env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash = block_hash;
546
547            debug!(
548                "Updated environment to newly produced block {} (hash: {})",
549                block_number, block_hash
550            );
551
552            Ok(())
553        })
554    }
555}
556
557/// Action that checks whether the broadcasted new payload has been accepted
558#[derive(Debug, Default)]
559pub struct CheckPayloadAccepted {}
560
561impl<Engine> Action<Engine> for CheckPayloadAccepted
562where
563    Engine: EngineTypes,
564    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
565{
566    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
567        Box::pin(async move {
568            let mut accepted_check: bool = false;
569
570            let latest_block = env
571                .current_block_info()
572                .ok_or_else(|| eyre::eyre!("No latest block information available"))?;
573
574            let payload_id = *env
575                .active_node_state()?
576                .payload_id_history
577                .get(&(latest_block.number + 1))
578                .ok_or_else(|| eyre::eyre!("Cannot find payload_id"))?;
579
580            let node_clients = env.node_clients.clone();
581            for (idx, client) in node_clients.iter().enumerate() {
582                let rpc_client = &client.rpc;
583
584                // get the last header by number using latest_head_number
585                let rpc_latest_header = EthApiClient::<
586                    TransactionRequest,
587                    Transaction,
588                    Block,
589                    Receipt,
590                    Header,
591                    TransactionSigned,
592                >::header_by_number(
593                    rpc_client, alloy_eips::BlockNumberOrTag::Latest
594                )
595                .await?
596                .ok_or_else(|| eyre::eyre!("No latest header found from rpc"))?;
597
598                // perform several checks
599                let next_new_payload = env
600                    .active_node_state()?
601                    .latest_payload_built
602                    .as_ref()
603                    .ok_or_else(|| eyre::eyre!("No next built payload found"))?;
604
605                let built_payload = EngineApiClient::<Engine>::get_payload_v3(
606                    &client.engine.http_client(),
607                    payload_id,
608                )
609                .await?;
610
611                let execution_payload_envelope: ExecutionPayloadEnvelopeV3 = built_payload.into();
612                let new_payload_block_hash = execution_payload_envelope
613                    .execution_payload
614                    .payload_inner
615                    .payload_inner
616                    .block_hash;
617
618                if rpc_latest_header.hash != new_payload_block_hash {
619                    debug!(
620                        "Client {}: The hash is not matched: {:?} {:?}",
621                        idx, rpc_latest_header.hash, new_payload_block_hash
622                    );
623                    continue;
624                }
625
626                if rpc_latest_header.inner.difficulty != alloy_primitives::U256::ZERO {
627                    debug!(
628                        "Client {}: difficulty != 0: {:?}",
629                        idx, rpc_latest_header.inner.difficulty
630                    );
631                    continue;
632                }
633
634                if rpc_latest_header.inner.mix_hash != next_new_payload.prev_randao {
635                    debug!(
636                        "Client {}: The mix_hash and prev_randao is not same: {:?} {:?}",
637                        idx, rpc_latest_header.inner.mix_hash, next_new_payload.prev_randao
638                    );
639                    continue;
640                }
641
642                let extra_len = rpc_latest_header.inner.extra_data.len();
643                if extra_len <= 32 {
644                    debug!("Client {}: extra_len is fewer than 32. extra_len: {}", idx, extra_len);
645                    continue;
646                }
647
648                // at least one client passes all the check, save the header in Env
649                if !accepted_check {
650                    accepted_check = true;
651                    // save the current block info in Env
652                    env.set_current_block_info(BlockInfo {
653                        hash: rpc_latest_header.hash,
654                        number: rpc_latest_header.inner.number,
655                        timestamp: rpc_latest_header.inner.timestamp,
656                    })?;
657
658                    // align latest header time and forkchoice state with the accepted canonical
659                    // head
660                    env.active_node_state_mut()?.latest_header_time =
661                        rpc_latest_header.inner.timestamp;
662                    env.active_node_state_mut()?.latest_fork_choice_state.head_block_hash =
663                        rpc_latest_header.hash;
664                }
665            }
666
667            if accepted_check {
668                Ok(())
669            } else {
670                Err(eyre::eyre!("No clients passed payload acceptance checks"))
671            }
672        })
673    }
674}
675
676/// Action that broadcasts the next new payload
677#[derive(Debug, Default)]
678pub struct BroadcastNextNewPayload {
679    /// If true, only send to the active node. If false, broadcast to all nodes.
680    active_node_only: bool,
681}
682
683impl BroadcastNextNewPayload {
684    /// Create a new `BroadcastNextNewPayload` action that only sends to the active node
685    pub const fn with_active_node() -> Self {
686        Self { active_node_only: true }
687    }
688}
689
690impl<Engine> Action<Engine> for BroadcastNextNewPayload
691where
692    Engine: EngineTypes + PayloadTypes,
693    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
694    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
695{
696    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
697        Box::pin(async move {
698            // Get the next new payload to broadcast
699            let next_new_payload = env
700                .active_node_state()?
701                .latest_payload_built
702                .as_ref()
703                .ok_or_else(|| eyre::eyre!("No next built payload found"))?
704                .clone();
705            let parent_beacon_block_root = next_new_payload
706                .parent_beacon_block_root
707                .ok_or_else(|| eyre::eyre!("No parent beacon block root for next new payload"))?;
708
709            let payload_envelope = env
710                .active_node_state()?
711                .latest_payload_envelope
712                .as_ref()
713                .ok_or_else(|| eyre::eyre!("No execution payload envelope available"))?
714                .clone();
715
716            let execution_payload_envelope: ExecutionPayloadEnvelopeV3 = payload_envelope.into();
717            let execution_payload = execution_payload_envelope.execution_payload;
718
719            if self.active_node_only {
720                // Send only to the active node
721                let active_idx = env.active_node_idx;
722                let engine = env.node_clients[active_idx].engine.http_client();
723
724                let result = EngineApiClient::<Engine>::new_payload_v3(
725                    &engine,
726                    execution_payload.clone(),
727                    vec![],
728                    parent_beacon_block_root,
729                )
730                .await?;
731
732                debug!("Active node {}: new_payload status: {:?}", active_idx, result.status);
733
734                // Validate the response
735                match result.status {
736                    PayloadStatusEnum::Valid => {
737                        env.active_node_state_mut()?.latest_payload_executed =
738                            Some(next_new_payload);
739                        Ok(())
740                    }
741                    other => Err(eyre::eyre!(
742                        "Active node {}: Unexpected payload status: {:?}",
743                        active_idx,
744                        other
745                    )),
746                }
747            } else {
748                // Loop through all clients and broadcast the next new payload
749                let mut broadcast_results = Vec::new();
750                let mut first_valid_seen = false;
751
752                for (idx, client) in env.node_clients.iter().enumerate() {
753                    let engine = client.engine.http_client();
754
755                    // Broadcast the execution payload
756                    let result = EngineApiClient::<Engine>::new_payload_v3(
757                        &engine,
758                        execution_payload.clone(),
759                        vec![],
760                        parent_beacon_block_root,
761                    )
762                    .await?;
763
764                    broadcast_results.push((idx, result.status.clone()));
765                    debug!("Node {}: new_payload broadcast status: {:?}", idx, result.status);
766
767                    // Check if this node accepted the payload
768                    if result.status == PayloadStatusEnum::Valid && !first_valid_seen {
769                        first_valid_seen = true;
770                    } else if let PayloadStatusEnum::Invalid { validation_error } = result.status {
771                        debug!(
772                            "Node {}: Invalid payload status returned from broadcast: {:?}",
773                            idx, validation_error
774                        );
775                    }
776                }
777
778                // Update the executed payload state after broadcasting to all nodes
779                if first_valid_seen {
780                    env.active_node_state_mut()?.latest_payload_executed = Some(next_new_payload);
781                }
782
783                // Check if at least one node accepted the payload
784                let any_valid =
785                    broadcast_results.iter().any(|(_, status)| *status == PayloadStatusEnum::Valid);
786                if !any_valid {
787                    return Err(eyre::eyre!(
788                        "Failed to successfully broadcast payload to any client"
789                    ));
790                }
791
792                debug!("Broadcast complete. Results: {:?}", broadcast_results);
793
794                Ok(())
795            }
796        })
797    }
798}
799
800/// Action that produces a sequence of blocks using the available clients
801#[derive(Debug)]
802pub struct ProduceBlocks<Engine> {
803    /// Number of blocks to produce
804    pub num_blocks: u64,
805    /// Tracks engine type
806    _phantom: PhantomData<Engine>,
807}
808
809impl<Engine> ProduceBlocks<Engine> {
810    /// Create a new `ProduceBlocks` action
811    pub fn new(num_blocks: u64) -> Self {
812        Self { num_blocks, _phantom: Default::default() }
813    }
814}
815
816impl<Engine> Default for ProduceBlocks<Engine> {
817    fn default() -> Self {
818        Self::new(0)
819    }
820}
821
822impl<Engine> Action<Engine> for ProduceBlocks<Engine>
823where
824    Engine: EngineTypes + PayloadTypes,
825    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
826    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
827{
828    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
829        Box::pin(async move {
830            for _ in 0..self.num_blocks {
831                // create a fresh sequence for each block to avoid state pollution
832                // Note: This produces blocks but does NOT make them canonical
833                // Use MakeCanonical action explicitly if canonicalization is needed
834                let mut sequence = Sequence::new(vec![
835                    Box::new(PickNextBlockProducer::default()),
836                    Box::new(GeneratePayloadAttributes::default()),
837                    Box::new(GenerateNextPayload::default()),
838                    Box::new(BroadcastNextNewPayload::default()),
839                    Box::new(UpdateBlockInfoToLatestPayload::default()),
840                ]);
841                sequence.execute(env).await?;
842            }
843            Ok(())
844        })
845    }
846}
847
848/// Action to test forkchoice update to a tagged block with expected status
849#[derive(Debug)]
850pub struct TestFcuToTag {
851    /// Tag name of the target block
852    pub tag: String,
853    /// Expected payload status
854    pub expected_status: PayloadStatusEnum,
855}
856
857impl TestFcuToTag {
858    /// Create a new `TestFcuToTag` action
859    pub fn new(tag: impl Into<String>, expected_status: PayloadStatusEnum) -> Self {
860        Self { tag: tag.into(), expected_status }
861    }
862}
863
864impl<Engine> Action<Engine> for TestFcuToTag
865where
866    Engine: EngineTypes,
867{
868    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
869        Box::pin(async move {
870            // get the target block from the registry
871            let (target_block, _node_idx) = env
872                .block_registry
873                .get(&self.tag)
874                .copied()
875                .ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?;
876
877            let engine_client = env.node_clients[0].engine.http_client();
878            let fcu_state = ForkchoiceState {
879                head_block_hash: target_block.hash,
880                safe_block_hash: target_block.hash,
881                finalized_block_hash: target_block.hash,
882            };
883
884            let fcu_response =
885                EngineApiClient::<Engine>::fork_choice_updated_v2(&engine_client, fcu_state, None)
886                    .await?;
887
888            // validate the response matches expected status
889            match (&fcu_response.payload_status.status, &self.expected_status) {
890                (PayloadStatusEnum::Valid, PayloadStatusEnum::Valid) => {
891                    debug!("FCU to '{}' returned VALID as expected", self.tag);
892                }
893                (PayloadStatusEnum::Invalid { .. }, PayloadStatusEnum::Invalid { .. }) => {
894                    debug!("FCU to '{}' returned INVALID as expected", self.tag);
895                }
896                (PayloadStatusEnum::Syncing, PayloadStatusEnum::Syncing) => {
897                    debug!("FCU to '{}' returned SYNCING as expected", self.tag);
898                }
899                (PayloadStatusEnum::Accepted, PayloadStatusEnum::Accepted) => {
900                    debug!("FCU to '{}' returned ACCEPTED as expected", self.tag);
901                }
902                (actual, expected) => {
903                    return Err(eyre::eyre!(
904                        "FCU to '{}': expected status {:?}, but got {:?}",
905                        self.tag,
906                        expected,
907                        actual
908                    ));
909                }
910            }
911
912            Ok(())
913        })
914    }
915}
916
917/// Action to expect a specific FCU status when targeting a tagged block
918#[derive(Debug)]
919pub struct ExpectFcuStatus {
920    /// Tag name of the target block
921    pub target_tag: String,
922    /// Expected payload status
923    pub expected_status: PayloadStatusEnum,
924}
925
926impl ExpectFcuStatus {
927    /// Create a new `ExpectFcuStatus` action expecting VALID status
928    pub fn valid(target_tag: impl Into<String>) -> Self {
929        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Valid }
930    }
931
932    /// Create a new `ExpectFcuStatus` action expecting INVALID status
933    pub fn invalid(target_tag: impl Into<String>) -> Self {
934        Self {
935            target_tag: target_tag.into(),
936            expected_status: PayloadStatusEnum::Invalid {
937                validation_error: "corrupted block".to_string(),
938            },
939        }
940    }
941
942    /// Create a new `ExpectFcuStatus` action expecting SYNCING status
943    pub fn syncing(target_tag: impl Into<String>) -> Self {
944        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Syncing }
945    }
946
947    /// Create a new `ExpectFcuStatus` action expecting ACCEPTED status
948    pub fn accepted(target_tag: impl Into<String>) -> Self {
949        Self { target_tag: target_tag.into(), expected_status: PayloadStatusEnum::Accepted }
950    }
951}
952
953impl<Engine> Action<Engine> for ExpectFcuStatus
954where
955    Engine: EngineTypes,
956{
957    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
958        Box::pin(async move {
959            let mut test_fcu = TestFcuToTag::new(&self.target_tag, self.expected_status.clone());
960            test_fcu.execute(env).await
961        })
962    }
963}
964
965/// Action to validate that a tagged block remains canonical by performing FCU to it
966#[derive(Debug)]
967pub struct ValidateCanonicalTag {
968    /// Tag name of the block to validate as canonical
969    pub tag: String,
970}
971
972impl ValidateCanonicalTag {
973    /// Create a new `ValidateCanonicalTag` action
974    pub fn new(tag: impl Into<String>) -> Self {
975        Self { tag: tag.into() }
976    }
977}
978
979impl<Engine> Action<Engine> for ValidateCanonicalTag
980where
981    Engine: EngineTypes,
982{
983    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
984        Box::pin(async move {
985            let mut expect_valid = ExpectFcuStatus::valid(&self.tag);
986            expect_valid.execute(env).await?;
987
988            debug!("Successfully validated that '{}' remains canonical", self.tag);
989            Ok(())
990        })
991    }
992}
993
994/// Action that produces blocks locally without broadcasting to other nodes
995/// This sends the payload only to the active node to ensure it's available locally
996#[derive(Debug)]
997pub struct ProduceBlocksLocally<Engine> {
998    /// Number of blocks to produce
999    pub num_blocks: u64,
1000    /// Tracks engine type
1001    _phantom: PhantomData<Engine>,
1002}
1003
1004impl<Engine> ProduceBlocksLocally<Engine> {
1005    /// Create a new `ProduceBlocksLocally` action
1006    pub fn new(num_blocks: u64) -> Self {
1007        Self { num_blocks, _phantom: Default::default() }
1008    }
1009}
1010
1011impl<Engine> Default for ProduceBlocksLocally<Engine> {
1012    fn default() -> Self {
1013        Self::new(0)
1014    }
1015}
1016
1017impl<Engine> Action<Engine> for ProduceBlocksLocally<Engine>
1018where
1019    Engine: EngineTypes + PayloadTypes,
1020    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
1021    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
1022{
1023    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
1024        Box::pin(async move {
1025            // Remember the active node to ensure all blocks are produced on the same node
1026            let producer_idx = env.active_node_idx;
1027
1028            for _ in 0..self.num_blocks {
1029                // Ensure we always use the same producer
1030                env.last_producer_idx = Some(producer_idx);
1031
1032                // create a sequence that produces blocks and sends only to active node
1033                let mut sequence = Sequence::new(vec![
1034                    // Skip PickNextBlockProducer to maintain the same producer
1035                    Box::new(GeneratePayloadAttributes::default()),
1036                    Box::new(GenerateNextPayload::default()),
1037                    // Send payload only to the active node to make it available
1038                    Box::new(BroadcastNextNewPayload::with_active_node()),
1039                    Box::new(UpdateBlockInfoToLatestPayload::default()),
1040                ]);
1041                sequence.execute(env).await?;
1042            }
1043            Ok(())
1044        })
1045    }
1046}
1047
1048/// Action that produces a sequence of blocks where some blocks are intentionally invalid
1049#[derive(Debug)]
1050pub struct ProduceInvalidBlocks<Engine> {
1051    /// Number of blocks to produce
1052    pub num_blocks: u64,
1053    /// Set of indices (0-based) where blocks should be made invalid
1054    pub invalid_indices: HashSet<u64>,
1055    /// Tracks engine type
1056    _phantom: PhantomData<Engine>,
1057}
1058
1059impl<Engine> ProduceInvalidBlocks<Engine> {
1060    /// Create a new `ProduceInvalidBlocks` action
1061    pub fn new(num_blocks: u64, invalid_indices: HashSet<u64>) -> Self {
1062        Self { num_blocks, invalid_indices, _phantom: Default::default() }
1063    }
1064
1065    /// Create a new `ProduceInvalidBlocks` action with a single invalid block at the specified
1066    /// index
1067    pub fn with_invalid_at(num_blocks: u64, invalid_index: u64) -> Self {
1068        let mut invalid_indices = HashSet::new();
1069        invalid_indices.insert(invalid_index);
1070        Self::new(num_blocks, invalid_indices)
1071    }
1072}
1073
1074impl<Engine> Action<Engine> for ProduceInvalidBlocks<Engine>
1075where
1076    Engine: EngineTypes + PayloadTypes,
1077    Engine::PayloadAttributes: From<PayloadAttributes> + Clone,
1078    Engine::ExecutionPayloadEnvelopeV3: Into<ExecutionPayloadEnvelopeV3>,
1079{
1080    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
1081        Box::pin(async move {
1082            for block_index in 0..self.num_blocks {
1083                let is_invalid = self.invalid_indices.contains(&block_index);
1084
1085                if is_invalid {
1086                    debug!("Producing invalid block at index {}", block_index);
1087
1088                    // produce a valid block first, then corrupt it
1089                    let mut sequence = Sequence::new(vec![
1090                        Box::new(PickNextBlockProducer::default()),
1091                        Box::new(GeneratePayloadAttributes::default()),
1092                        Box::new(GenerateNextPayload::default()),
1093                    ]);
1094                    sequence.execute(env).await?;
1095
1096                    // get the latest payload and corrupt it
1097                    let latest_envelope =
1098                        env.active_node_state()?.latest_payload_envelope.as_ref().ok_or_else(
1099                            || eyre::eyre!("No payload envelope available to corrupt"),
1100                        )?;
1101
1102                    let envelope_v3: ExecutionPayloadEnvelopeV3 = latest_envelope.clone().into();
1103                    let mut corrupted_payload = envelope_v3.execution_payload;
1104
1105                    // corrupt the state root to make the block invalid
1106                    corrupted_payload.payload_inner.payload_inner.state_root = B256::random();
1107
1108                    debug!(
1109                        "Corrupted state root for block {} to: {}",
1110                        block_index, corrupted_payload.payload_inner.payload_inner.state_root
1111                    );
1112
1113                    // send the corrupted payload via newPayload
1114                    let engine_client = env.node_clients[0].engine.http_client();
1115                    // for simplicity, we'll use empty versioned hashes for invalid block testing
1116                    let versioned_hashes = Vec::new();
1117                    // use a random parent beacon block root since this is for invalid block testing
1118                    let parent_beacon_block_root = B256::random();
1119
1120                    let new_payload_response = EngineApiClient::<Engine>::new_payload_v3(
1121                        &engine_client,
1122                        corrupted_payload.clone(),
1123                        versioned_hashes,
1124                        parent_beacon_block_root,
1125                    )
1126                    .await?;
1127
1128                    // expect the payload to be rejected as invalid
1129                    match new_payload_response.status {
1130                        PayloadStatusEnum::Invalid { validation_error } => {
1131                            debug!(
1132                                "Block {} correctly rejected as invalid: {:?}",
1133                                block_index, validation_error
1134                            );
1135                        }
1136                        other_status => {
1137                            return Err(eyre::eyre!(
1138                                "Expected block {} to be rejected as INVALID, but got: {:?}",
1139                                block_index,
1140                                other_status
1141                            ));
1142                        }
1143                    }
1144
1145                    // update block info with the corrupted block (for potential future reference)
1146                    env.set_current_block_info(BlockInfo {
1147                        hash: corrupted_payload.payload_inner.payload_inner.block_hash,
1148                        number: corrupted_payload.payload_inner.payload_inner.block_number,
1149                        timestamp: corrupted_payload.timestamp(),
1150                    })?;
1151                } else {
1152                    debug!("Producing valid block at index {}", block_index);
1153
1154                    // produce a valid block normally
1155                    let mut sequence = Sequence::new(vec![
1156                        Box::new(PickNextBlockProducer::default()),
1157                        Box::new(GeneratePayloadAttributes::default()),
1158                        Box::new(GenerateNextPayload::default()),
1159                        Box::new(BroadcastNextNewPayload::default()),
1160                        Box::new(UpdateBlockInfoToLatestPayload::default()),
1161                    ]);
1162                    sequence.execute(env).await?;
1163                }
1164            }
1165            Ok(())
1166        })
1167    }
1168}