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
31const DIFF_TIMEOUT: Duration = Duration::from_secs(1);
36
37const MAX_RETAINED_BLOCKS: usize = 100;
39
40#[derive(Debug, PartialEq, Eq)]
42struct BundleStateSorted {
43 pub state: BTreeMap<Address, BundleAccountSorted>,
45 pub contracts: BTreeMap<B256, Bytecode>,
47 pub reverts: Vec<Vec<(Address, AccountRevertSorted)>>,
53 pub state_size: usize,
55 pub reverts_size: usize,
57}
58
59#[derive(Debug, PartialEq, Eq)]
61struct BundleAccountSorted {
62 pub info: Option<AccountInfo>,
63 pub original_info: Option<AccountInfo>,
64 pub storage: BTreeMap<U256, StorageSlot>,
70 pub status: AccountStatus,
72}
73
74#[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
83fn 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#[derive(Clone, Debug)]
134pub struct InvalidBlockWitnessHook<P, E> {
135 provider: P,
137 evm_config: E,
139 output_directory: PathBuf,
142 healthy_node_client: Option<RpcClient>,
144 runtime: Runtime,
146 task_permit: Arc<Semaphore>,
148}
149
150impl<P, E> InvalidBlockWitnessHook<P, E> {
151 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 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 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 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 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 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 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 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 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 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 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
417fn 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 fn create_bundle_state() -> BundleState {
467 let mut rng = generators::rng();
468 let mut bundle_state = BundleState::default();
469
470 let accounts = random_eoa_accounts(&mut rng, 3);
472
473 for (i, (addr, account)) in accounts.into_iter().enumerate() {
474 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 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]), 1 => Bytes::from(vec![0x61, 0x81, 0x60, 0x00, 0x39]), _ => Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xfd]), };
514 bundle_state.contracts.insert(*hash, Bytecode::new_raw(bytecode));
515 }
516
517 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, ..AccountRevert::default()
523 };
524 bundle_state.reverts.push(vec![(*addr, revert)]);
525 }
526
527 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 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 let result = hook.validate_bundle_state(&modified_state, &original_state, block_prefix);
627 assert!(result.is_ok());
628
629 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 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, 0b1010, 0b1000, vec![B256::repeat_byte(1u8)], None, );
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 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); let block_prefix = "test_state_root_with_trie";
673 let hashed_state = hashed_state_for_bundle_state(bundle_state);
674
675 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 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])), );
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 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 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 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 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 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 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 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}