Skip to main content

reth_invalid_block_hooks/
witness.rs

1use crate::Runtime;
2use alloy_consensus::BlockHeader;
3use alloy_eips::BlockId;
4use alloy_primitives::{Address, B256, U256};
5use alloy_rpc_client::RpcClient;
6use alloy_rpc_types_debug::ExecutionWitness;
7use evm2::{bytecode::Bytecode, evm::AccountInfo};
8use reth_engine_primitives::InvalidBlockHook;
9use reth_evm::{database::StateProviderDatabase, ConfigureEvm, Executor};
10use reth_execution_types::BundleSource;
11use reth_primitives_traits::{NodePrimitives, RecoveredBlock, SealedHeader};
12use reth_provider::{BlockExecutionOutput, StateProvider, StateProviderFactory};
13use reth_trie::{
14    bundle_state::{AccountInfoRevert, AccountStatus, BundleState, RevertToSlot, StorageSlot},
15    updates::TrieUpdates,
16};
17use serde::Serialize;
18use similar::TextDiff;
19use std::{
20    collections::BTreeMap,
21    fmt::Debug,
22    fs::File,
23    io::{BufWriter, Write},
24    path::{Path, PathBuf},
25    sync::Arc,
26    time::Duration,
27};
28use tokio::sync::Semaphore;
29use tracing::{debug, error, warn};
30
31/// Time budget for computing a diff before the remaining lines are reported as replaced.
32///
33/// The bundle state of a large block renders to hundreds of thousands of lines. Bound the
34/// diff search so that one block cannot monopolize the background worker.
35const DIFF_TIMEOUT: Duration = Duration::from_secs(1);
36
37/// Number of invalid blocks whose output files are kept in the output directory.
38const MAX_RETAINED_BLOCKS: usize = 100;
39
40/// Serializable version of `BundleState` for deterministic comparison
41#[derive(Debug, PartialEq, Eq)]
42struct BundleStateSorted {
43    /// Account state
44    pub state: BTreeMap<Address, BundleAccountSorted>,
45    /// All created contracts in this block.
46    pub contracts: BTreeMap<B256, Bytecode>,
47    /// Changes to revert
48    ///
49    /// **Note**: Inside vector is *not* sorted by address.
50    ///
51    /// But it is unique by address.
52    pub reverts: Vec<Vec<(Address, AccountRevertSorted)>>,
53    /// The size of the plain state in the bundle state
54    pub state_size: usize,
55    /// The size of reverts in the bundle state
56    pub reverts_size: usize,
57}
58
59/// Serializable version of `BundleAccount`
60#[derive(Debug, PartialEq, Eq)]
61struct BundleAccountSorted {
62    pub info: Option<AccountInfo>,
63    pub original_info: Option<AccountInfo>,
64    /// Contains both original and present state.
65    /// When extracting changeset we compare if original value is different from present value.
66    /// If it is different we add it to changeset.
67    /// If Account was destroyed we ignore original value and compare present state with
68    /// `U256::ZERO`.
69    pub storage: BTreeMap<U256, StorageSlot>,
70    /// Account status.
71    pub status: AccountStatus,
72}
73
74/// Serializable version of `AccountRevert`
75#[derive(Debug, PartialEq, Eq)]
76struct AccountRevertSorted {
77    pub account: AccountInfoRevert,
78    pub storage: BTreeMap<U256, RevertToSlot>,
79    pub previous_status: AccountStatus,
80    pub wipe_storage: bool,
81}
82
83/// Converts bundle state to sorted format for deterministic comparison
84fn sort_bundle_state_for_comparison(bundle_state: &BundleState) -> BundleStateSorted {
85    BundleStateSorted {
86        state: bundle_state
87            .state
88            .iter()
89            .map(|(addr, acc)| {
90                (
91                    *addr,
92                    BundleAccountSorted {
93                        info: acc.info.clone(),
94                        original_info: acc.original_info.clone(),
95                        storage: acc.storage.iter().map(|(k, v)| (*k, *v)).collect(),
96                        status: acc.status,
97                    },
98                )
99            })
100            .collect(),
101        contracts: bundle_state.contracts.iter().map(|(k, v)| (*k, v.clone())).collect(),
102        reverts: bundle_state
103            .reverts
104            .iter()
105            .map(|block| {
106                block
107                    .iter()
108                    .map(|(addr, rev)| {
109                        (
110                            *addr,
111                            AccountRevertSorted {
112                                account: rev.account.clone(),
113                                storage: rev.storage.iter().map(|(k, v)| (*k, *v)).collect(),
114                                previous_status: rev.previous_status,
115                                wipe_storage: rev.wipe_storage,
116                            },
117                        )
118                    })
119                    .collect()
120            })
121            .collect(),
122        state_size: bundle_state.state_size,
123        reverts_size: bundle_state.reverts_size,
124    }
125}
126
127/// Hook for generating execution witnesses when invalid blocks are detected.
128///
129/// This hook captures the execution state and generates witness data that can be used
130/// for debugging and analysis of invalid block execution. Diagnostics run on a blocking task;
131/// additional invalid blocks are skipped while a task is running or queued. Only the output of
132/// the highest invalid blocks is kept.
133#[derive(Clone, Debug)]
134pub struct InvalidBlockWitnessHook<P, E> {
135    /// The provider to read the historical state and do the EVM execution.
136    provider: P,
137    /// The EVM configuration to use for the execution.
138    evm_config: E,
139    /// The directory to write the witness to. Additionally, diff files will be written to this
140    /// directory in case of failed sanity checks.
141    output_directory: PathBuf,
142    /// The healthy node client to compare the witness against.
143    healthy_node_client: Option<RpcClient>,
144    /// Runtime for background diagnostics and shutdown-aware RPC requests.
145    runtime: Runtime,
146    /// Allows at most one diagnostic task, including across clones of the hook.
147    task_permit: Arc<Semaphore>,
148}
149
150impl<P, E> InvalidBlockWitnessHook<P, E> {
151    /// Creates a witness hook that runs diagnostics on the given runtime.
152    ///
153    /// If `healthy_node_client` is `None`, no remote witness comparison is performed.
154    pub fn new(
155        provider: P,
156        evm_config: E,
157        output_directory: PathBuf,
158        healthy_node_client: Option<RpcClient>,
159        runtime: Runtime,
160    ) -> Self {
161        Self {
162            provider,
163            evm_config,
164            output_directory,
165            healthy_node_client,
166            runtime,
167            task_permit: Arc::new(Semaphore::new(1)),
168        }
169    }
170}
171
172impl<P, E, N> InvalidBlockWitnessHook<P, E>
173where
174    P: StateProviderFactory + 'static,
175    E: ConfigureEvm<Primitives = N> + 'static,
176    N: NodePrimitives,
177{
178    /// Re-executes the block and collects execution data
179    fn re_execute_block(
180        &self,
181        parent_header: &SealedHeader<N::BlockHeader>,
182        block: &RecoveredBlock<N::Block>,
183    ) -> eyre::Result<(ExecutionWitness, BundleState, reth_trie::HashedPostState)> {
184        let state_provider = self.provider.state_by_block_hash(parent_header.hash())?;
185        let mut database = evm2::evm::CacheDB::new(evm2::evm::Db::new(StateProviderDatabase::new(
186            state_provider.as_ref().into_evm_state_provider(),
187        )));
188        let output = self.evm_config.executor(&mut database).execute(block)?;
189        let hashed_state = state_provider.hashed_post_state(&output.state)?;
190        database.commit_source(&BundleSource(&output.state));
191        let witness = reth_evm::witness::ExecutionWitnessRecord::new(&database)
192            .into_execution_witness_without_headers(
193                state_provider.as_ref(),
194                reth_trie::ExecutionWitnessMode::Legacy,
195            )?;
196        let bundle_state = output.state;
197
198        Ok((witness, bundle_state, hashed_state))
199    }
200
201    /// Handles witness generation, saving, and comparison with healthy node
202    async fn handle_witness_operations(
203        &self,
204        witness: &ExecutionWitness,
205        block_prefix: &str,
206        block_number: u64,
207    ) -> eyre::Result<()> {
208        let filename = format!("{}.witness.re_executed.json", block_prefix);
209        let re_executed_witness_path = self.save_file(filename, witness)?;
210
211        if let Some(healthy_node_client) = &self.healthy_node_client {
212            let healthy_node_witness: ExecutionWitness = healthy_node_client
213                .request("debug_executionWitness", (BlockId::from(block_number),))
214                .await?;
215
216            let filename = format!("{}.witness.healthy.json", block_prefix);
217            let healthy_path = self.save_file(filename, &healthy_node_witness)?;
218
219            if witness != &healthy_node_witness {
220                let filename = format!("{}.witness.diff", block_prefix);
221                let diff_path = self.save_diff(filename, witness, &healthy_node_witness)?;
222                warn!(
223                    target: "engine::invalid_block_hooks::witness",
224                    diff_path = %diff_path.display(),
225                    re_executed_path = %re_executed_witness_path.display(),
226                    healthy_path = %healthy_path.display(),
227                    "Witness mismatch against healthy node"
228                );
229            }
230        }
231        Ok(())
232    }
233
234    /// Validates that the bundle state after re-execution matches the original
235    fn validate_bundle_state(
236        &self,
237        re_executed_state: &BundleState,
238        original_state: &BundleState,
239        block_prefix: &str,
240    ) -> eyre::Result<()> {
241        if re_executed_state != original_state {
242            let original_filename = format!("{}.bundle_state.original.json", block_prefix);
243            let original_path = self.save_file(original_filename, original_state)?;
244            let re_executed_filename = format!("{}.bundle_state.re_executed.json", block_prefix);
245            let re_executed_path = self.save_file(re_executed_filename, re_executed_state)?;
246
247            // Convert bundle state to sorted format for deterministic comparison
248            let bundle_state_sorted = sort_bundle_state_for_comparison(re_executed_state);
249            let output_state_sorted = sort_bundle_state_for_comparison(original_state);
250            let filename = format!("{}.bundle_state.diff", block_prefix);
251            let diff_path = self.save_diff(filename, &output_state_sorted, &bundle_state_sorted)?;
252
253            warn!(
254                target: "engine::invalid_block_hooks::witness",
255                diff_path = %diff_path.display(),
256                original_path = %original_path.display(),
257                re_executed_path = %re_executed_path.display(),
258                "Bundle state mismatch after re-execution"
259            );
260        }
261        Ok(())
262    }
263
264    /// Validates state root and trie updates after re-execution
265    fn validate_state_root_and_trie(
266        &self,
267        parent_header: &SealedHeader<N::BlockHeader>,
268        block: &RecoveredBlock<N::Block>,
269        hashed_state: reth_trie::HashedPostState,
270        trie_updates: Option<(&TrieUpdates, B256)>,
271        block_prefix: &str,
272    ) -> eyre::Result<()> {
273        let state_provider = self.provider.state_by_block_hash(parent_header.hash())?;
274        let (re_executed_root, trie_output) =
275            state_provider.state_root_with_updates(hashed_state)?;
276
277        if let Some((original_updates, original_root)) = trie_updates {
278            if re_executed_root != original_root {
279                let filename = format!("{}.state_root.diff", block_prefix);
280                let diff_path = self.save_diff(filename, &original_root, &re_executed_root)?;
281                warn!(target: "engine::invalid_block_hooks::witness", ?original_root, ?re_executed_root, diff_path = %diff_path.display(), "State root mismatch after re-execution");
282            }
283
284            if re_executed_root != block.state_root() {
285                let filename = format!("{}.header_state_root.diff", block_prefix);
286                let diff_path = self.save_diff(filename, &block.state_root(), &re_executed_root)?;
287                warn!(target: "engine::invalid_block_hooks::witness", header_state_root=?block.state_root(), ?re_executed_root, diff_path = %diff_path.display(), "Re-executed state root does not match block state root");
288            }
289
290            if &trie_output != original_updates {
291                let original_path = self.save_file(
292                    format!("{}.trie_updates.original.json", block_prefix),
293                    &original_updates.to_sorted_ref(),
294                )?;
295                let re_executed_path = self.save_file(
296                    format!("{}.trie_updates.re_executed.json", block_prefix),
297                    &trie_output.to_sorted_ref(),
298                )?;
299                warn!(
300                    target: "engine::invalid_block_hooks::witness",
301                    original_path = %original_path.display(),
302                    re_executed_path = %re_executed_path.display(),
303                    "Trie updates mismatch after re-execution"
304                );
305            }
306        }
307        Ok(())
308    }
309
310    /// Runs diagnostics on a blocking executor, yielding during the healthy-node RPC request.
311    async fn process_invalid_block(
312        &self,
313        parent_header: &SealedHeader<N::BlockHeader>,
314        block: &RecoveredBlock<N::Block>,
315        original_state: &BundleState,
316        trie_updates: Option<(&TrieUpdates, B256)>,
317    ) -> eyre::Result<()> {
318        if let Err(err) = prune_output(&self.output_directory) {
319            error!(target: "engine::invalid_block_hooks::witness", %err, "Failed to prune hook output");
320        }
321
322        // TODO(alexey): unify with `DebugApi::debug_execution_witness`
323        let (witness, bundle_state, hashed_state) = self.re_execute_block(parent_header, block)?;
324
325        let block_prefix = format!("{}_{}", block.number(), block.hash());
326        self.handle_witness_operations(&witness, &block_prefix, block.number()).await?;
327
328        self.validate_bundle_state(&bundle_state, original_state, &block_prefix)?;
329
330        self.validate_state_root_and_trie(
331            parent_header,
332            block,
333            hashed_state,
334            trie_updates,
335            &block_prefix,
336        )?;
337
338        Ok(())
339    }
340
341    /// Serializes and saves a value to a JSON file in the output directory
342    fn save_file<T: Serialize>(&self, filename: String, value: &T) -> eyre::Result<PathBuf> {
343        let path = self.output_directory.join(filename);
344        let mut writer = BufWriter::new(File::create(&path)?);
345        serde_json::to_writer(&mut writer, value)?;
346        writer.flush()?;
347
348        Ok(path)
349    }
350
351    /// Compares the debug output of two values and saves their unified line diff to a file in the
352    /// output directory.
353    ///
354    /// Myers' algorithm keeps memory linear in the input size, and [`DIFF_TIMEOUT`] bounds its
355    /// running time.
356    fn save_diff<T: Debug>(
357        &self,
358        filename: String,
359        original: &T,
360        new: &T,
361    ) -> eyre::Result<PathBuf> {
362        let path = self.output_directory.join(filename);
363        let original = format!("{original:#?}\n");
364        let new = format!("{new:#?}\n");
365        let diff = TextDiff::configure().timeout(DIFF_TIMEOUT).diff_lines(&original, &new);
366        File::create(&path)?
367            .write_all(diff.unified_diff().header("original", "new").to_string().as_bytes())?;
368
369        Ok(path)
370    }
371}
372
373impl<P, E, N: NodePrimitives> InvalidBlockHook<N> for InvalidBlockWitnessHook<P, E>
374where
375    P: StateProviderFactory + Clone + 'static,
376    E: ConfigureEvm<Primitives = N> + 'static,
377{
378    fn on_invalid_block(
379        &self,
380        parent_header: &SealedHeader<N::BlockHeader>,
381        block: &RecoveredBlock<N::Block>,
382        output: &BlockExecutionOutput<N::Receipt>,
383        trie_updates: Option<(&TrieUpdates, B256)>,
384    ) {
385        // Reserve capacity before cloning potentially large diagnostic inputs.
386        let Ok(permit) = self.task_permit.clone().try_acquire_owned() else {
387            metrics::counter!("engine.invalid_block_hooks.witness.skipped_blocks_total")
388                .increment(1);
389            debug!(target: "engine::invalid_block_hooks::witness", block_hash = %block.hash(), "Skipping diagnostics while witness hook is busy");
390            return
391        };
392
393        let hook = self.clone();
394        let parent_header = parent_header.clone();
395        let block = block.clone();
396        let original_state = output.state.clone();
397        let trie_updates = trie_updates.map(|(updates, root)| (updates.clone(), root));
398        self.runtime.spawn_blocking_task(async move {
399            let _permit = permit;
400            debug!(target: "engine::invalid_block_hooks::witness", block_hash = %block.hash(), "Starting witness diagnostics");
401            if let Err(err) = hook
402                .process_invalid_block(
403                    &parent_header,
404                    &block,
405                    &original_state,
406                    trie_updates.as_ref().map(|(updates, root)| (updates, *root)),
407                )
408                .await
409            {
410                error!(target: "engine::invalid_block_hooks::witness", block_hash = %block.hash(), %err, "Failed to invoke hook");
411            }
412            debug!(target: "engine::invalid_block_hooks::witness", block_hash = %block.hash(), "Finished witness diagnostics");
413        });
414    }
415}
416
417/// Removes the output files of all but the highest blocks, so that the output of
418/// [`MAX_RETAINED_BLOCKS`] blocks remains once the next block is written.
419///
420/// Only files named `{block_number}_{block_hash}.*` are considered, others are left alone.
421fn prune_output(output_directory: &Path) -> eyre::Result<()> {
422    let mut blocks = BTreeMap::<(u64, String), Vec<PathBuf>>::new();
423    for entry in reth_fs_util::read_dir(output_directory)? {
424        let entry = entry?;
425        let file_name = entry.file_name();
426        if let Some((prefix, _)) = file_name.to_str().and_then(|name| name.split_once('.')) &&
427            let Some((number, hash)) = prefix.split_once('_') &&
428            let Ok(number) = number.parse::<u64>() &&
429            hash.parse::<B256>().is_ok()
430        {
431            blocks.entry((number, prefix.to_string())).or_default().push(entry.path());
432        }
433    }
434
435    for path in blocks.into_values().rev().skip(MAX_RETAINED_BLOCKS - 1).flatten() {
436        reth_fs_util::remove_file(path)?;
437    }
438
439    Ok(())
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use alloy_consensus::Header;
446    use alloy_primitives::{map::HashMap, Bytes, B256, U256};
447    use alloy_rpc_client::ClientBuilder;
448    use evm2::evm::{CacheDB, EmptyDB};
449    use jsonrpsee::{
450        core::RpcResult,
451        server::{ServerBuilder, ServerHandle},
452        RpcModule,
453    };
454    use reth_chainspec::ChainSpec;
455    use reth_ethereum_primitives::{Block, EthPrimitives};
456    use reth_evm_ethereum::EthEvmConfig;
457    use reth_provider::test_utils::MockEthProvider;
458    use reth_trie::bundle_state::{AccountRevert, BundleAccount};
459    use tempfile::TempDir;
460    use tokio::{sync::Notify, time::timeout};
461
462    use reth_testing_utils::generators::{self, random_block, random_eoa_accounts, BlockParams};
463    use std::collections::HashSet;
464
465    /// Creates a test `BundleState` with realistic accounts, contracts, and reverts
466    fn create_bundle_state() -> BundleState {
467        let mut rng = generators::rng();
468        let mut bundle_state = BundleState::default();
469
470        // Generate realistic EOA accounts using generators
471        let accounts = random_eoa_accounts(&mut rng, 3);
472
473        for (i, (addr, account)) in accounts.into_iter().enumerate() {
474            // Create storage entries for each account
475            let mut storage = HashMap::default();
476            let storage_key = U256::from(i + 1);
477            storage.insert(
478                storage_key,
479                StorageSlot {
480                    present_value: U256::from((i + 1) * 10),
481                    previous_or_original_value: U256::from((i + 1) * 15),
482                },
483            );
484
485            let bundle_account = BundleAccount {
486                info: Some(AccountInfo {
487                    balance: account.balance,
488                    nonce: account.nonce,
489                    code_hash: account.bytecode_hash.unwrap_or_default(),
490                    code: None,
491                    ..Default::default()
492                }),
493                original_info: (i == 0).then(|| AccountInfo {
494                    balance: account.balance.checked_div(U256::from(2)).unwrap_or(U256::ZERO),
495                    code_hash: account.bytecode_hash.unwrap_or_default(),
496                    code: None,
497                    ..Default::default()
498                }),
499                storage,
500                status: AccountStatus::default(),
501            };
502
503            bundle_state.state.insert(addr, bundle_account);
504        }
505
506        // Generate realistic contract bytecode using generators
507        let contract_hashes: Vec<B256> = (0..3).map(|_| B256::random()).collect();
508        for (i, hash) in contract_hashes.iter().enumerate() {
509            let bytecode = match i {
510                0 => Bytes::from(vec![0x60, 0x80, 0x60, 0x40, 0x52]), // Simple contract
511                1 => Bytes::from(vec![0x61, 0x81, 0x60, 0x00, 0x39]), // Another contract
512                _ => Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xfd]), // REVERT contract
513            };
514            bundle_state.contracts.insert(*hash, Bytecode::new_raw(bytecode));
515        }
516
517        // Add reverts for multiple blocks using different accounts
518        let addresses: Vec<Address> = bundle_state.state.keys().copied().collect();
519        for (i, addr) in addresses.iter().take(2).enumerate() {
520            let revert = AccountRevert {
521                wipe_storage: i == 0, // First account has storage wiped
522                ..AccountRevert::default()
523            };
524            bundle_state.reverts.push(vec![(*addr, revert)]);
525        }
526
527        // Set realistic sizes
528        bundle_state.state_size = bundle_state.state.len();
529        bundle_state.reverts_size = bundle_state.reverts.len();
530
531        bundle_state
532    }
533
534    fn hashed_state_for_bundle_state(bundle_state: BundleState) -> reth_trie::HashedPostState {
535        reth_trie::HashedPostState::from_bundle_state::<reth_trie::KeccakKeyHasher>(
536            &bundle_state.state,
537        )
538    }
539
540    #[test]
541    fn test_sort_bundle_state_for_comparison() {
542        let sorted = sort_bundle_state_for_comparison(&create_bundle_state());
543        assert_eq!(sorted.state_size, 3);
544        assert_eq!(sorted.reverts_size, 2);
545        assert_eq!(sorted.state.len(), 3);
546        assert_eq!(sorted.contracts.len(), 3);
547        assert_eq!(sorted.reverts.len(), 2);
548    }
549
550    /// Creates test `InvalidBlockWitnessHook` with temporary directory
551    fn create_test_hook() -> (
552        InvalidBlockWitnessHook<MockEthProvider<EthPrimitives, ChainSpec>, EthEvmConfig>,
553        PathBuf,
554        TempDir,
555    ) {
556        let temp_dir = TempDir::new().expect("Failed to create temp dir");
557        let output_directory = temp_dir.path().to_path_buf();
558
559        let provider = MockEthProvider::<EthPrimitives, ChainSpec>::default();
560        let evm_config = EthEvmConfig::mainnet();
561
562        let hook = InvalidBlockWitnessHook::new(
563            provider,
564            evm_config,
565            output_directory.clone(),
566            None,
567            Runtime::test(),
568        );
569
570        (hook, output_directory, temp_dir)
571    }
572
573    #[tokio::test]
574    async fn test_handle_witness_operations_file_creation() {
575        let (hook, output_dir, _temp_dir) = create_test_hook();
576        let witness = ExecutionWitness {
577            state: vec![Bytes::from("state")],
578            codes: vec![Bytes::from("code")],
579            keys: vec![Bytes::from("key")],
580            ..Default::default()
581        };
582        hook.handle_witness_operations(&witness, "block", 0).await.unwrap();
583        let saved = std::fs::read(output_dir.join("block.witness.re_executed.json")).unwrap();
584        assert_eq!(serde_json::from_slice::<ExecutionWitness>(&saved).unwrap(), witness);
585    }
586
587    #[test]
588    fn witness_includes_unchanged_account_storage_and_code_reads() {
589        let provider = MockEthProvider::default();
590        let mut database = CacheDB::<EmptyDB>::default();
591        let address = Address::repeat_byte(1);
592        let slot = U256::from(7);
593        let code = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00]));
594        database.insert_account_info(&address, AccountInfo::default().with_code(code.clone()));
595        database.insert_account_storage(&address, &slot, &U256::from(8));
596        let witness = reth_evm::witness::ExecutionWitnessRecord::new(&database)
597            .into_execution_witness_without_headers(
598                &provider,
599                reth_trie::ExecutionWitnessMode::Legacy,
600            )
601            .unwrap();
602        assert!(witness.codes.contains(&code.original_bytes()));
603        assert!(witness.keys.contains(&Bytes::copy_from_slice(address.as_slice())));
604        assert!(witness.keys.contains(&Bytes::copy_from_slice(B256::from(slot).as_slice())));
605    }
606
607    #[test]
608    fn test_validate_bundle_state_matching() {
609        let (hook, output_dir, _temp_dir) = create_test_hook();
610        for state in [BundleState::default(), create_bundle_state()] {
611            hook.validate_bundle_state(&state, &state, "matching").unwrap();
612            assert_eq!(output_dir.read_dir().unwrap().count(), 0);
613        }
614    }
615
616    #[test]
617    fn test_validate_bundle_state_mismatch() {
618        let (hook, output_dir, _temp_dir) = create_test_hook();
619        let original_state = create_bundle_state();
620        let mut modified_state = original_state.clone();
621        modified_state.state.values_mut().next().unwrap().info.as_mut().unwrap().nonce ^= 1;
622
623        let block_prefix = "test_block_mismatch";
624
625        // Test with different states - should save files and log warning
626        let result = hook.validate_bundle_state(&modified_state, &original_state, block_prefix);
627        assert!(result.is_ok());
628
629        // Verify that files were created
630        let original_file = output_dir.join(format!("{}.bundle_state.original.json", block_prefix));
631        let re_executed_file =
632            output_dir.join(format!("{}.bundle_state.re_executed.json", block_prefix));
633        let diff_file = output_dir.join(format!("{}.bundle_state.diff", block_prefix));
634
635        assert!(original_file.exists(), "Original bundle state file should be created");
636        assert!(re_executed_file.exists(), "Re-executed bundle state file should be created");
637        assert!(diff_file.exists(), "Diff file should be created");
638    }
639
640    /// Creates test `TrieUpdates` with account nodes and removed nodes
641    fn create_test_trie_updates() -> TrieUpdates {
642        use alloy_primitives::map::HashMap;
643        use reth_trie::{updates::TrieUpdates, BranchNodeCompact, Nibbles};
644
645        let mut account_nodes = HashMap::default();
646        let nibbles = Nibbles::from_nibbles_unchecked([0x1, 0x2, 0x3]);
647        let branch_node = BranchNodeCompact::new(
648            0b1010,                       // state_mask
649            0b1010,                       // tree_mask - must be subset of state_mask
650            0b1000,                       // hash_mask
651            vec![B256::repeat_byte(1u8)], // hashes
652            None,                         // root_hash
653        );
654        account_nodes.insert(nibbles, branch_node);
655
656        let mut removed_nodes = HashSet::default();
657        removed_nodes.insert(Nibbles::from_nibbles_unchecked([0x4, 0x5, 0x6]));
658
659        TrieUpdates { account_nodes, removed_nodes, storage_tries: HashMap::default() }
660    }
661
662    #[test]
663    fn test_validate_state_root_and_trie_with_trie_updates() {
664        let (hook, output_dir, _temp_dir) = create_test_hook();
665        let bundle_state = create_bundle_state();
666
667        // Generate test data
668        let (parent_header, recovered_block) = invalid_block_fixture(2);
669
670        let trie_updates = create_test_trie_updates();
671        let original_root = B256::repeat_byte(2u8); // Different from what will be computed
672        let block_prefix = "test_state_root_with_trie";
673        let hashed_state = hashed_state_for_bundle_state(bundle_state);
674
675        // Test with trie updates - this will likely produce warnings due to mock data
676        let result = hook.validate_state_root_and_trie(
677            &parent_header,
678            &recovered_block,
679            hashed_state,
680            Some((&trie_updates, original_root)),
681            block_prefix,
682        );
683        result.unwrap();
684        assert!(output_dir.join(format!("{block_prefix}.state_root.diff")).exists());
685        assert!(output_dir.join(format!("{block_prefix}.trie_updates.original.json")).exists());
686        assert!(output_dir.join(format!("{block_prefix}.trie_updates.re_executed.json")).exists());
687    }
688
689    #[test]
690    fn test_validate_bundle_state_with_different_contract_counts() {
691        let (hook, output_dir, _temp_dir) = create_test_hook();
692        let state1 = create_bundle_state();
693        let mut state2 = state1.clone();
694
695        // Add extra contract to state2
696        let extra_contract_hash = B256::random();
697        state2.contracts.insert(
698            extra_contract_hash,
699            Bytecode::new_raw(Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xfd])), // REVERT opcode
700        );
701
702        let block_prefix = "different_contracts_test";
703        let result = hook.validate_bundle_state(&state1, &state2, block_prefix);
704        assert!(result.is_ok());
705
706        // Verify diff files were created
707        let diff_file = output_dir.join(format!("{}.bundle_state.diff", block_prefix));
708        assert!(diff_file.exists());
709    }
710
711    #[test]
712    fn test_save_diff() {
713        let (hook, _output_dir, _temp_dir) = create_test_hook();
714        for (new, expected) in [(1, ""), (2, "--- original\n+++ new\n@@ -1 +1 @@\n-1\n+2\n")] {
715            let path = hook.save_diff("diff".to_string(), &1, &new).unwrap();
716            assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
717        }
718    }
719
720    #[test]
721    fn test_validate_state_root_and_trie_without_trie_updates() {
722        let (hook, output_dir, _temp_dir) = create_test_hook();
723        let bundle_state = create_bundle_state();
724
725        let (parent_header, recovered_block) = invalid_block_fixture(2);
726
727        let block_prefix = "no_trie_updates_test";
728        let hashed_state = hashed_state_for_bundle_state(bundle_state);
729
730        // Test without trie updates (None case)
731        let result = hook.validate_state_root_and_trie(
732            &parent_header,
733            &recovered_block,
734            hashed_state,
735            None,
736            block_prefix,
737        );
738        result.unwrap();
739        assert_eq!(output_dir.read_dir().unwrap().count(), 0);
740    }
741
742    #[test]
743    fn test_save_diff_large_bundle_state() {
744        const ACCOUNTS: usize = 3_000;
745        let (hook, _output_dir, _temp_dir) = create_test_hook();
746        let original = sort_bundle_state_for_comparison(&create_transfer_bundle_state(ACCOUNTS, 0));
747        let new =
748            sort_bundle_state_for_comparison(&create_transfer_bundle_state(ACCOUNTS, ACCOUNTS));
749
750        // Every account differs, so the changed region spans the whole state. A quadratic line
751        // diff of it would allocate tens of gigabytes.
752        let path = hook.save_diff("large_diff_test".to_string(), &original, &new).unwrap();
753
754        let diff = std::fs::read_to_string(path).unwrap();
755        let added: HashSet<_> =
756            diff.lines().filter_map(|line| line.strip_prefix('+')).map(str::trim).collect();
757        for balance in ACCOUNTS..2 * ACCOUNTS {
758            assert!(added.contains(format!("balance: {balance},").as_str()));
759        }
760    }
761
762    /// Creates a `BundleState` of `accounts` funded accounts, offsetting every balance by
763    /// `balance_offset`.
764    fn create_transfer_bundle_state(accounts: usize, balance_offset: usize) -> BundleState {
765        let mut bundle_state = BundleState::default();
766        for i in 0..accounts {
767            bundle_state.state.insert(
768                Address::from_word(B256::from(U256::from(i))),
769                BundleAccount {
770                    info: Some(AccountInfo {
771                        balance: U256::from(i + balance_offset),
772                        ..Default::default()
773                    }),
774                    original_info: None,
775                    storage: HashMap::default(),
776                    status: AccountStatus::Changed,
777                },
778            );
779        }
780        bundle_state.state_size = accounts;
781        bundle_state
782    }
783
784    #[tokio::test]
785    async fn test_on_invalid_block_runs_in_background_and_skips_while_busy() {
786        let (mut hook, output_dir, _temp_dir) = create_test_hook();
787        let (client, server, requested, respond) = healthy_node_server().await;
788        hook.healthy_node_client = Some(client);
789        let (parent, block) = invalid_block_fixture(2);
790        let (skipped_parent, skipped_block) = invalid_block_fixture(3);
791        let output = BlockExecutionOutput::default();
792        let healthy_path = output_dir.join(format!("2_{}.witness.healthy.json", block.hash()));
793        let skipped_path =
794            output_dir.join(format!("3_{}.witness.re_executed.json", skipped_block.hash()));
795
796        hook.on_invalid_block(&parent, &block, &output, None);
797        timeout(Duration::from_secs(10), requested.notified()).await.unwrap();
798        assert!(!healthy_path.exists());
799
800        // Clones share the admission limit and must not queue another diagnostic job.
801        hook.clone().on_invalid_block(&skipped_parent, &skipped_block, &output, None);
802        respond.notify_one();
803        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
804        assert!(healthy_path.exists());
805        assert!(!skipped_path.exists());
806
807        hook.healthy_node_client = None;
808        hook.on_invalid_block(&skipped_parent, &skipped_block, &output, None);
809        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
810        assert!(skipped_path.exists());
811        server.stop().unwrap();
812        timeout(Duration::from_secs(10), server.stopped()).await.unwrap();
813    }
814
815    #[tokio::test]
816    async fn test_on_invalid_block_releases_capacity_after_error() {
817        let (mut hook, output_dir, _temp_dir) = create_test_hook();
818        let (parent, block) = invalid_block_fixture(2);
819        let output = BlockExecutionOutput::default();
820        hook.output_directory = output_dir.join("missing");
821
822        hook.on_invalid_block(&parent, &block, &output, None);
823        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
824        assert!(!hook.output_directory.exists());
825
826        hook.output_directory = output_dir.clone();
827        hook.on_invalid_block(&parent, &block, &output, None);
828        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
829        assert!(output_dir
830            .join(format!("{}_{}.witness.re_executed.json", block.number(), block.hash()))
831            .exists());
832    }
833
834    #[tokio::test]
835    async fn test_on_invalid_block_cancels_rpc_on_shutdown() {
836        let (mut hook, output_dir, _temp_dir) = create_test_hook();
837        let (client, server, requested, respond) = healthy_node_server().await;
838        hook.healthy_node_client = Some(client);
839        let (parent, block) = invalid_block_fixture(2);
840
841        hook.on_invalid_block(&parent, &block, &BlockExecutionOutput::default(), None);
842        timeout(Duration::from_secs(10), requested.notified()).await.unwrap();
843        drop(hook.runtime.initiate_graceful_shutdown().unwrap());
844        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
845        assert!(!output_dir
846            .join(format!("{}_{}.witness.healthy.json", block.number(), block.hash()))
847            .exists());
848
849        respond.notify_one();
850        server.stop().unwrap();
851        timeout(Duration::from_secs(10), server.stopped()).await.unwrap();
852    }
853
854    fn invalid_block_fixture(number: u64) -> (SealedHeader<Header>, RecoveredBlock<Block>) {
855        let mut rng = generators::rng();
856        let parent = generators::random_header(&mut rng, number - 1, None);
857        let block = random_block(
858            &mut rng,
859            number,
860            BlockParams { parent: Some(parent.hash()), tx_count: Some(0), ..Default::default() },
861        )
862        .try_recover()
863        .unwrap();
864        (parent, block)
865    }
866
867    async fn healthy_node_server() -> (RpcClient, ServerHandle, Arc<Notify>, Arc<Notify>) {
868        let requested = Arc::new(Notify::new());
869        let respond = Arc::new(Notify::new());
870        let server = ServerBuilder::default().build("127.0.0.1:0").await.unwrap();
871        let client = ClientBuilder::default()
872            .http(format!("http://{}", server.local_addr().unwrap()).parse().unwrap());
873        let mut module = RpcModule::new((requested.clone(), respond.clone()));
874        module
875            .register_async_method("debug_executionWitness", |_, context| async move {
876                context.0.notify_one();
877                context.1.notified().await;
878                RpcResult::Ok(ExecutionWitness {
879                    codes: vec![Bytes::from_static(b"healthy node code")],
880                    ..Default::default()
881                })
882            })
883            .unwrap();
884        (client, server.start(module), requested, respond)
885    }
886
887    #[test]
888    fn test_prune_output_keeps_highest_blocks() {
889        let temp_dir = TempDir::new().unwrap();
890        let output_dir = temp_dir.path();
891        let blocks = (0..MAX_RETAINED_BLOCKS as u64 + 2)
892            .map(|number| {
893                let prefix = format!("{number}_{}", B256::with_last_byte(number as u8));
894                let files = [
895                    output_dir.join(format!("{prefix}.witness.re_executed.json")),
896                    output_dir.join(format!("{prefix}.bundle_state.diff")),
897                ];
898                for path in &files {
899                    File::create(path).unwrap();
900                }
901                files
902            })
903            .collect::<Vec<_>>();
904        let unrelated = output_dir.join("notes.txt");
905        File::create(&unrelated).unwrap();
906
907        prune_output(output_dir).unwrap();
908
909        // Room is left for one more block, so the three lowest blocks are removed.
910        for (number, files) in blocks.iter().enumerate() {
911            for path in files {
912                assert_eq!(path.exists(), number >= 3, "{}", path.display());
913            }
914        }
915        assert!(unrelated.exists());
916    }
917
918    #[tokio::test]
919    async fn test_on_invalid_block_prunes_old_output() {
920        let (hook, output_dir, _temp_dir) = create_test_hook();
921        let old_blocks = (0..MAX_RETAINED_BLOCKS as u64)
922            .map(|number| {
923                let path = output_dir.join(format!(
924                    "{number}_{}.witness.re_executed.json",
925                    B256::with_last_byte(number as u8)
926                ));
927                File::create(&path).unwrap();
928                path
929            })
930            .collect::<Vec<_>>();
931
932        let (parent_header, block) = invalid_block_fixture(100);
933        let output = BlockExecutionOutput::default();
934        let permit = hook.task_permit.try_acquire().unwrap();
935
936        // Busy hooks must skip pruning as well as writing.
937        hook.on_invalid_block(&parent_header, &block, &output, None);
938        assert!(old_blocks[0].exists());
939        drop(permit);
940
941        hook.on_invalid_block(&parent_header, &block, &output, None);
942        drop(timeout(Duration::from_secs(10), hook.task_permit.acquire()).await.unwrap().unwrap());
943        assert!(!old_blocks[0].exists());
944        assert!(old_blocks[1..].iter().all(|path| path.exists()));
945        assert!(output_dir
946            .join(format!("{}_{}.witness.re_executed.json", block.number(), block.hash()))
947            .exists());
948    }
949}