Skip to main content

reth_stages/stages/
tx_lookup.rs

1use alloy_consensus::transaction::TxHashRef;
2use alloy_primitives::{TxHash, TxNumber};
3use num_traits::Zero;
4use reth_config::config::{EtlConfig, TransactionLookupConfig};
5use reth_db_api::{
6    table::{Decode, Decompress, Value},
7    tables,
8    transaction::DbTxMut,
9    Tables,
10};
11use reth_etl::Collector;
12use reth_primitives_traits::{NodePrimitives, SignedTransaction};
13use reth_provider::{
14    DBProvider, EitherWriter, PruneCheckpointReader, PruneCheckpointWriter, RocksDBProviderFactory,
15    StaticFileProviderFactory, StatsReader, StorageSettingsCache, TransactionsProvider,
16    TransactionsProviderExt,
17};
18use reth_prune_types::{PruneCheckpoint, PruneMode, PrunePurpose, PruneSegment};
19use reth_stages_api::{
20    EntitiesCheckpoint, ExecInput, ExecOutput, Stage, StageCheckpoint, StageError, StageId,
21    UnwindInput, UnwindOutput,
22};
23use reth_storage_errors::provider::ProviderError;
24use tracing::*;
25
26/// The transaction lookup stage.
27///
28/// This stage walks over existing transactions, and sets the transaction hash of each transaction
29/// in a block to the corresponding `BlockNumber` at each block. This is written to the
30/// [`tables::TransactionHashNumbers`] This is used for looking up changesets via the transaction
31/// hash.
32///
33/// It uses [`reth_etl::Collector`] to collect all entries before finally writing them to disk.
34#[derive(Debug, Clone)]
35pub struct TransactionLookupStage {
36    /// The maximum number of lookup entries to hold in memory before pushing them to
37    /// [`reth_etl::Collector`].
38    chunk_size: u64,
39    etl_config: EtlConfig,
40    prune_mode: Option<PruneMode>,
41}
42
43impl Default for TransactionLookupStage {
44    fn default() -> Self {
45        Self { chunk_size: 5_000_000, etl_config: EtlConfig::default(), prune_mode: None }
46    }
47}
48
49impl TransactionLookupStage {
50    /// Create new instance of [`TransactionLookupStage`].
51    pub const fn new(
52        config: TransactionLookupConfig,
53        etl_config: EtlConfig,
54        prune_mode: Option<PruneMode>,
55    ) -> Self {
56        Self { chunk_size: config.chunk_size, etl_config, prune_mode }
57    }
58}
59
60impl<Provider> Stage<Provider> for TransactionLookupStage
61where
62    Provider: DBProvider<Tx: DbTxMut>
63        + PruneCheckpointWriter
64        + PruneCheckpointReader
65        + StatsReader
66        + StaticFileProviderFactory<Primitives: NodePrimitives<SignedTx: Value + SignedTransaction>>
67        + TransactionsProviderExt
68        + StorageSettingsCache
69        + RocksDBProviderFactory,
70{
71    /// Return the id of the stage
72    fn id(&self) -> StageId {
73        StageId::TransactionLookup
74    }
75
76    /// Write transaction hash -> id entries
77    fn execute(
78        &mut self,
79        provider: &Provider,
80        mut input: ExecInput,
81    ) -> Result<ExecOutput, StageError> {
82        if let Some((target_prunable_block, prune_mode)) = self
83            .prune_mode
84            .map(|mode| {
85                mode.prune_target_block(
86                    input.target(),
87                    PruneSegment::TransactionLookup,
88                    PrunePurpose::User,
89                )
90            })
91            .transpose()?
92            .flatten() &&
93            target_prunable_block > input.checkpoint().block_number
94        {
95            input.checkpoint = Some(StageCheckpoint::new(target_prunable_block));
96
97            // Save prune checkpoint only if we don't have one already.
98            // Otherwise, pruner may skip the unpruned range of blocks.
99            if provider.get_prune_checkpoint(PruneSegment::TransactionLookup)?.is_none() {
100                let target_prunable_tx_number = provider
101                    .block_body_indices(target_prunable_block)?
102                    .ok_or(ProviderError::BlockBodyIndicesNotFound(target_prunable_block))?
103                    .last_tx_num();
104
105                provider.save_prune_checkpoint(
106                    PruneSegment::TransactionLookup,
107                    PruneCheckpoint {
108                        block_number: Some(target_prunable_block),
109                        tx_number: Some(target_prunable_tx_number),
110                        prune_mode,
111                    },
112                )?;
113            }
114        }
115        if input.target_reached() {
116            return Ok(ExecOutput::done(input.checkpoint()));
117        }
118
119        // 500MB temporary files
120        let mut hash_collector: Collector<TxHash, TxNumber> =
121            Collector::new(self.etl_config.file_size, self.etl_config.dir.clone());
122
123        info!(
124            target: "sync::stages::transaction_lookup",
125            tx_range = ?input.checkpoint().block_number..=input.target(),
126            "Updating transaction lookup"
127        );
128
129        loop {
130            let Some(range_output) =
131                input.next_block_range_with_transaction_threshold(provider, self.chunk_size)?
132            else {
133                input.checkpoint = Some(
134                    StageCheckpoint::new(input.target())
135                        .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
136                );
137                break;
138            };
139
140            let end_block = *range_output.block_range.end();
141
142            info!(target: "sync::stages::transaction_lookup", tx_range = ?range_output.tx_range, "Calculating transaction hashes");
143
144            for (key, value) in provider.transaction_hashes_by_range(range_output.tx_range)? {
145                hash_collector.insert(key, value)?;
146            }
147
148            input.checkpoint = Some(
149                StageCheckpoint::new(end_block)
150                    .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
151            );
152
153            if range_output.is_final_range {
154                let total_hashes = hash_collector.len();
155                let interval = (total_hashes / 10).max(1);
156
157                // Use append mode when table is empty (first sync) - significantly faster
158                let append_only =
159                    provider.count_entries::<tables::TransactionHashNumbers>()?.is_zero();
160
161                // Auto-commits on threshold; consistency check heals any crash.
162                provider.with_rocksdb_batch_auto_commit(|rocksdb_batch| {
163                    let mut writer =
164                        EitherWriter::new_transaction_hash_numbers(provider, rocksdb_batch)?;
165
166                    let iter = hash_collector
167                        .iter()
168                        .map_err(|e| ProviderError::other(Box::new(e)))?;
169
170                    for (index, hash_to_number) in iter.enumerate() {
171                        let (hash_bytes, number_bytes) =
172                            hash_to_number.map_err(|e| ProviderError::other(Box::new(e)))?;
173                        if index > 0 && index.is_multiple_of(interval) {
174                            info!(
175                                target: "sync::stages::transaction_lookup",
176                                ?append_only,
177                                progress = %format_args!("{:.2}%", (index as f64 / total_hashes as f64) * 100.0),
178                                "Inserting hashes"
179                            );
180                        }
181
182                        // Decode from raw ETL bytes
183                        let hash = TxHash::decode(&hash_bytes)?;
184                        let tx_num = TxNumber::decompress(&number_bytes)?;
185                        writer.put_transaction_hash_number(hash, tx_num, append_only)?;
186                    }
187
188                    Ok(((), writer.into_raw_rocksdb_batch()))
189                })?;
190
191                trace!(target: "sync::stages::transaction_lookup",
192                    total_hashes,
193                    "Transaction hashes inserted"
194                );
195
196                break;
197            }
198        }
199
200        if provider.cached_storage_settings().storage_v2 {
201            provider.commit_pending_rocksdb_batches()?;
202            provider.rocksdb_provider().flush(&[Tables::TransactionHashNumbers.name()])?;
203        }
204
205        Ok(ExecOutput::done(
206            StageCheckpoint::new(input.target())
207                .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
208        ))
209    }
210
211    /// Unwind the stage.
212    fn unwind(
213        &mut self,
214        provider: &Provider,
215        input: UnwindInput,
216    ) -> Result<UnwindOutput, StageError> {
217        let (range, unwind_to, _) = input.unwind_block_range_with_threshold(self.chunk_size);
218
219        provider.with_rocksdb_batch(|rocksdb_batch| {
220            let mut writer = EitherWriter::new_transaction_hash_numbers(provider, rocksdb_batch)?;
221
222            let static_file_provider = provider.static_file_provider();
223            let rev_walker = provider
224                .block_body_indices_range(range.clone())?
225                .into_iter()
226                .rev()
227                .zip(range.rev());
228
229            for (body, number) in rev_walker {
230                if number <= unwind_to {
231                    break;
232                }
233
234                // Delete all transactions that belong to this block
235                for transaction in
236                    static_file_provider.transactions_by_tx_range(body.tx_num_range())?
237                {
238                    writer.delete_transaction_hash_number(*transaction.tx_hash())?;
239                }
240            }
241
242            Ok(((), writer.into_raw_rocksdb_batch()))
243        })?;
244
245        Ok(UnwindOutput {
246            checkpoint: StageCheckpoint::new(unwind_to)
247                .with_entities_stage_checkpoint(stage_checkpoint(provider)?),
248        })
249    }
250}
251
252fn stage_checkpoint<Provider>(provider: &Provider) -> Result<EntitiesCheckpoint, StageError>
253where
254    Provider: PruneCheckpointReader + StaticFileProviderFactory + StatsReader,
255{
256    let pruned_entries = provider
257        .get_prune_checkpoint(PruneSegment::TransactionLookup)?
258        .and_then(|checkpoint| checkpoint.tx_number)
259        // `+1` is needed because `TxNumber` is 0-indexed
260        .map(|tx_number| tx_number + 1)
261        .unwrap_or_default();
262    Ok(EntitiesCheckpoint {
263        // If `TransactionHashNumbers` table was pruned, we will have a number of entries in it not
264        // matching the actual number of processed transactions. To fix that, we add the
265        // number of pruned `TransactionHashNumbers` entries.
266        processed: provider.count_entries::<tables::TransactionHashNumbers>()? as u64 +
267            pruned_entries,
268        // Count only static files entries. If we count the database entries too, we may have
269        // duplicates. We're sure that the static files have all entries that database has,
270        // because we run the `StaticFileProducer` before starting the pipeline.
271        total: provider.static_file_provider().count_entries::<tables::Transactions>()? as u64,
272    })
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::test_utils::{
279        stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, StorageKind,
280        TestRunnerError, TestStageDB, UnwindStageTestRunner,
281    };
282    use alloy_primitives::{BlockNumber, B256};
283    use assert_matches::assert_matches;
284    use reth_db_api::{cursor::DbCursorRO, transaction::DbTx};
285    use reth_ethereum_primitives::Block;
286    use reth_primitives_traits::SealedBlock;
287    use reth_provider::{
288        providers::StaticFileWriter, BlockBodyIndicesProvider, DatabaseProviderFactory,
289    };
290    use reth_stages_api::StageUnitCheckpoint;
291    use reth_testing_utils::generators::{
292        self, random_block, random_block_range, BlockParams, BlockRangeParams,
293    };
294    use std::ops::Sub;
295
296    // Implement stage test suite.
297    stage_test_suite_ext!(TransactionLookupTestRunner, transaction_lookup);
298
299    #[tokio::test]
300    async fn execute_single_transaction_lookup() {
301        let (previous_stage, stage_progress) = (500, 100);
302        let mut rng = generators::rng();
303
304        // Set up the runner
305        let runner = TransactionLookupTestRunner::default();
306        let input = ExecInput {
307            target: Some(previous_stage),
308            checkpoint: Some(StageCheckpoint::new(stage_progress)),
309        };
310
311        // Insert blocks with a single transaction at block `stage_progress + 10`
312        let non_empty_block_number = stage_progress + 10;
313        let blocks = (stage_progress..=input.target())
314            .map(|number| {
315                random_block(
316                    &mut rng,
317                    number,
318                    BlockParams {
319                        tx_count: Some((number == non_empty_block_number) as u8),
320                        ..Default::default()
321                    },
322                )
323            })
324            .collect::<Vec<_>>();
325        runner
326            .db
327            .insert_blocks(blocks.iter(), StorageKind::Static)
328            .expect("failed to insert blocks");
329
330        let rx = runner.execute(input);
331
332        // Assert the successful result
333        let result = rx.await.unwrap();
334        assert_matches!(
335            result,
336            Ok(ExecOutput {
337                checkpoint: StageCheckpoint {
338                block_number,
339                stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint {
340                    processed,
341                    total
342                }))
343            }, done: true }) if block_number == previous_stage && processed == total &&
344                total == runner.db.count_entries::<tables::Transactions>().unwrap() as u64
345        );
346
347        // Validate the stage execution
348        assert!(runner.validate_execution(input, result.ok()).is_ok(), "execution validation");
349    }
350
351    #[tokio::test]
352    async fn execute_pruned_transaction_lookup() {
353        let (previous_stage, prune_target, stage_progress) = (500, 400, 100);
354        let mut rng = generators::rng();
355
356        // Set up the runner
357        let mut runner = TransactionLookupTestRunner::default();
358        let input = ExecInput {
359            target: Some(previous_stage),
360            checkpoint: Some(StageCheckpoint::new(stage_progress)),
361        };
362
363        // Seed only once with full input range
364        let seed = random_block_range(
365            &mut rng,
366            stage_progress + 1..=previous_stage,
367            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..2, ..Default::default() },
368        );
369        runner
370            .db
371            .insert_blocks(seed.iter(), StorageKind::Static)
372            .expect("failed to seed execution");
373
374        runner.set_prune_mode(PruneMode::Before(prune_target));
375
376        let rx = runner.execute(input);
377
378        // Assert the successful result
379        let result = rx.await.unwrap();
380        assert_matches!(
381            result,
382            Ok(ExecOutput {
383                checkpoint: StageCheckpoint {
384                block_number,
385                stage_checkpoint: Some(StageUnitCheckpoint::Entities(EntitiesCheckpoint {
386                    processed,
387                    total
388                }))
389            }, done: true }) if block_number == previous_stage && processed == total &&
390                total == runner.db.count_entries::<tables::Transactions>().unwrap() as u64
391        );
392
393        // Validate the stage execution
394        assert!(runner.validate_execution(input, result.ok()).is_ok(), "execution validation");
395    }
396
397    #[test]
398    fn stage_checkpoint_pruned() {
399        let db = TestStageDB::default();
400        let mut rng = generators::rng();
401
402        let blocks = random_block_range(
403            &mut rng,
404            0..=100,
405            BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..10, ..Default::default() },
406        );
407        db.insert_blocks(blocks.iter(), StorageKind::Static).expect("insert blocks");
408
409        let max_pruned_block = 30;
410        let max_processed_block = 70;
411
412        let mut tx_hash_numbers = Vec::new();
413        let mut tx_hash_number = 0;
414        for block in &blocks[..=max_processed_block] {
415            for transaction in &block.body().transactions {
416                if block.number > max_pruned_block {
417                    tx_hash_numbers.push((*transaction.tx_hash(), tx_hash_number));
418                }
419                tx_hash_number += 1;
420            }
421        }
422        db.insert_tx_hash_numbers(tx_hash_numbers).expect("insert tx hash numbers");
423
424        let provider = db.factory.provider_rw().unwrap();
425        provider
426            .save_prune_checkpoint(
427                PruneSegment::TransactionLookup,
428                PruneCheckpoint {
429                    block_number: Some(max_pruned_block),
430                    tx_number: Some(
431                        blocks[..=max_pruned_block as usize]
432                            .iter()
433                            .map(|block| block.transaction_count() as u64)
434                            .sum::<u64>()
435                            .sub(1), // `TxNumber` is 0-indexed
436                    ),
437                    prune_mode: PruneMode::Full,
438                },
439            )
440            .expect("save stage checkpoint");
441        provider.commit().expect("commit");
442
443        let provider = db.factory.database_provider_rw().unwrap();
444        assert_eq!(
445            stage_checkpoint(&provider).expect("stage checkpoint"),
446            EntitiesCheckpoint {
447                processed: blocks[..=max_processed_block]
448                    .iter()
449                    .map(|block| block.transaction_count() as u64)
450                    .sum(),
451                total: blocks.iter().map(|block| block.transaction_count() as u64).sum()
452            }
453        );
454    }
455
456    struct TransactionLookupTestRunner {
457        db: TestStageDB,
458        chunk_size: u64,
459        etl_config: EtlConfig,
460        prune_mode: Option<PruneMode>,
461    }
462
463    impl Default for TransactionLookupTestRunner {
464        fn default() -> Self {
465            Self {
466                db: TestStageDB::default(),
467                chunk_size: 1000,
468                etl_config: EtlConfig::default(),
469                prune_mode: None,
470            }
471        }
472    }
473
474    impl TransactionLookupTestRunner {
475        fn set_prune_mode(&mut self, prune_mode: PruneMode) {
476            self.prune_mode = Some(prune_mode);
477        }
478
479        /// # Panics
480        ///
481        /// 1. If there are any entries in the [`tables::TransactionHashNumbers`] table above a
482        ///    given block number.
483        /// 2. If there is no requested block entry in the bodies table, but
484        ///    [`tables::TransactionHashNumbers`] is    not empty.
485        fn ensure_no_hash_by_block(&self, number: BlockNumber) -> Result<(), TestRunnerError> {
486            let body_result = self
487                .db
488                .factory
489                .provider_rw()?
490                .block_body_indices(number)?
491                .ok_or(ProviderError::BlockBodyIndicesNotFound(number));
492            match body_result {
493                Ok(body) => {
494                    self.db.ensure_no_entry_above_by_value::<tables::TransactionHashNumbers, _>(
495                        body.last_tx_num(),
496                        |key| key,
497                    )?
498                }
499                Err(_) => {
500                    assert!(self.db.table_is_empty::<tables::TransactionHashNumbers>()?);
501                }
502            };
503
504            Ok(())
505        }
506    }
507
508    impl StageTestRunner for TransactionLookupTestRunner {
509        type S = TransactionLookupStage;
510
511        fn db(&self) -> &TestStageDB {
512            &self.db
513        }
514
515        fn stage(&self) -> Self::S {
516            TransactionLookupStage {
517                chunk_size: self.chunk_size,
518                etl_config: self.etl_config.clone(),
519                prune_mode: self.prune_mode,
520            }
521        }
522    }
523
524    impl ExecuteStageTestRunner for TransactionLookupTestRunner {
525        type Seed = Vec<SealedBlock<Block>>;
526
527        fn seed_execution(&mut self, input: ExecInput) -> Result<Self::Seed, TestRunnerError> {
528            let stage_progress = input.checkpoint().block_number;
529            let end = input.target();
530            let mut rng = generators::rng();
531
532            let blocks = random_block_range(
533                &mut rng,
534                stage_progress + 1..=end,
535                BlockRangeParams { parent: Some(B256::ZERO), tx_count: 0..2, ..Default::default() },
536            );
537            self.db.insert_blocks(blocks.iter(), StorageKind::Static)?;
538            Ok(blocks)
539        }
540
541        fn validate_execution(
542            &self,
543            mut input: ExecInput,
544            output: Option<ExecOutput>,
545        ) -> Result<(), TestRunnerError> {
546            match output {
547                Some(output) => {
548                    let provider = self.db.factory.provider()?;
549
550                    if let Some((target_prunable_block, _)) = self
551                        .prune_mode
552                        .map(|mode| {
553                            mode.prune_target_block(
554                                input.target(),
555                                PruneSegment::TransactionLookup,
556                                PrunePurpose::User,
557                            )
558                        })
559                        .transpose()
560                        .expect("prune target block for transaction lookup")
561                        .flatten() &&
562                        target_prunable_block > input.checkpoint().block_number
563                    {
564                        input.checkpoint = Some(StageCheckpoint::new(target_prunable_block));
565                    }
566                    let start_block = input.next_block();
567                    let end_block = output.checkpoint.block_number;
568
569                    if start_block > end_block {
570                        return Ok(())
571                    }
572
573                    let mut body_cursor =
574                        provider.tx_ref().cursor_read::<tables::BlockBodyIndices>()?;
575                    body_cursor.seek_exact(start_block)?;
576
577                    while let Some((_, body)) = body_cursor.next()? {
578                        for tx_id in body.tx_num_range() {
579                            let transaction =
580                                provider.transaction_by_id(tx_id)?.expect("no transaction entry");
581                            assert_eq!(
582                                Some(tx_id),
583                                provider.transaction_id(*transaction.tx_hash())?
584                            );
585                        }
586                    }
587                }
588                None => self.ensure_no_hash_by_block(input.checkpoint().block_number)?,
589            };
590            Ok(())
591        }
592    }
593
594    impl UnwindStageTestRunner for TransactionLookupTestRunner {
595        fn validate_unwind(&self, input: UnwindInput) -> Result<(), TestRunnerError> {
596            self.ensure_no_hash_by_block(input.unwind_to)
597        }
598    }
599
600    mod rocksdb_tests {
601        use super::*;
602        use reth_provider::RocksDBProviderFactory;
603        use reth_storage_api::StorageSettings;
604
605        /// Test that when `transaction_hash_numbers_in_rocksdb` is enabled, the stage
606        /// writes transaction hash mappings to `RocksDB` instead of MDBX.
607        #[tokio::test]
608        async fn execute_writes_to_rocksdb_when_enabled() {
609            let (previous_stage, stage_progress) = (110, 100);
610            let mut rng = generators::rng();
611
612            // Set up the runner
613            let runner = TransactionLookupTestRunner::default();
614
615            // Enable RocksDB for transaction hash numbers
616            runner.db.factory.set_storage_settings_cache(StorageSettings::v2());
617
618            let input = ExecInput {
619                target: Some(previous_stage),
620                checkpoint: Some(StageCheckpoint::new(stage_progress)),
621            };
622
623            // Insert blocks with transactions
624            let blocks = random_block_range(
625                &mut rng,
626                stage_progress + 1..=previous_stage,
627                BlockRangeParams {
628                    parent: Some(B256::ZERO),
629                    tx_count: 1..3, // Ensure we have transactions
630                    ..Default::default()
631                },
632            );
633            runner
634                .db
635                .insert_blocks(blocks.iter(), StorageKind::Static)
636                .expect("failed to insert blocks");
637
638            // Count expected transactions
639            let expected_tx_count: usize = blocks.iter().map(|b| b.body().transactions.len()).sum();
640            assert!(expected_tx_count > 0, "test requires at least one transaction");
641
642            // Execute the stage
643            let rx = runner.execute(input);
644            let result = rx.await.unwrap();
645            assert!(result.is_ok(), "stage execution failed: {:?}", result);
646
647            // Verify MDBX table is empty (data should be in RocksDB)
648            let mdbx_count = runner.db.count_entries::<tables::TransactionHashNumbers>().unwrap();
649            assert_eq!(
650                mdbx_count, 0,
651                "MDBX TransactionHashNumbers should be empty when RocksDB is enabled"
652            );
653
654            // Verify RocksDB has the data
655            let rocksdb = runner.db.factory.rocksdb_provider();
656            let mut rocksdb_count = 0;
657            for block in &blocks {
658                for tx in &block.body().transactions {
659                    let hash = *tx.tx_hash();
660                    let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
661                    assert!(result.is_some(), "Transaction hash {:?} not found in RocksDB", hash);
662                    rocksdb_count += 1;
663                }
664            }
665            assert_eq!(
666                rocksdb_count, expected_tx_count,
667                "RocksDB should contain all transaction hashes"
668            );
669        }
670
671        /// Test that when `transaction_hash_numbers_in_rocksdb` is enabled, the stage
672        /// unwind deletes transaction hash mappings from `RocksDB` instead of MDBX.
673        #[tokio::test]
674        async fn unwind_deletes_from_rocksdb_when_enabled() {
675            let (previous_stage, stage_progress) = (110, 100);
676            let mut rng = generators::rng();
677
678            // Set up the runner
679            let runner = TransactionLookupTestRunner::default();
680
681            // Enable RocksDB for transaction hash numbers
682            runner.db.factory.set_storage_settings_cache(StorageSettings::v2());
683
684            // Insert blocks with transactions
685            let blocks = random_block_range(
686                &mut rng,
687                stage_progress + 1..=previous_stage,
688                BlockRangeParams {
689                    parent: Some(B256::ZERO),
690                    tx_count: 1..3, // Ensure we have transactions
691                    ..Default::default()
692                },
693            );
694            runner
695                .db
696                .insert_blocks(blocks.iter(), StorageKind::Static)
697                .expect("failed to insert blocks");
698
699            // Count expected transactions
700            let expected_tx_count: usize = blocks.iter().map(|b| b.body().transactions.len()).sum();
701            assert!(expected_tx_count > 0, "test requires at least one transaction");
702
703            // Execute the stage first to populate RocksDB
704            let exec_input = ExecInput {
705                target: Some(previous_stage),
706                checkpoint: Some(StageCheckpoint::new(stage_progress)),
707            };
708            let rx = runner.execute(exec_input);
709            let result = rx.await.unwrap();
710            assert!(result.is_ok(), "stage execution failed: {:?}", result);
711
712            // Verify RocksDB has the data before unwind
713            let rocksdb = runner.db.factory.rocksdb_provider();
714            for block in &blocks {
715                for tx in &block.body().transactions {
716                    let hash = *tx.tx_hash();
717                    let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
718                    assert!(
719                        result.is_some(),
720                        "Transaction hash {:?} should exist before unwind",
721                        hash
722                    );
723                }
724            }
725
726            // Now unwind to stage_progress (removing all the blocks we added)
727            let unwind_input = UnwindInput {
728                checkpoint: StageCheckpoint::new(previous_stage),
729                unwind_to: stage_progress,
730                bad_block: None,
731            };
732            let unwind_result = runner.unwind(unwind_input).await;
733            assert!(unwind_result.is_ok(), "stage unwind failed: {:?}", unwind_result);
734
735            // Verify RocksDB data is deleted after unwind
736            let rocksdb = runner.db.factory.rocksdb_provider();
737            for block in &blocks {
738                for tx in &block.body().transactions {
739                    let hash = *tx.tx_hash();
740                    let result = rocksdb.get::<tables::TransactionHashNumbers>(hash).unwrap();
741                    assert!(
742                        result.is_none(),
743                        "Transaction hash {:?} should be deleted from RocksDB after unwind",
744                        hash
745                    );
746                }
747            }
748        }
749    }
750}