1mod archive;
81pub mod config_gen;
82mod extract;
83mod fetch;
84pub mod manifest;
85pub mod manifest_cmd;
86mod planning;
87mod progress;
88mod session;
89mod source;
90mod tui;
91mod verify;
92
93use crate::common::EnvironmentArgs;
94use archive::run_modular_downloads;
95use clap::{builder::RangedU64ValueParser, Parser};
96use config_gen::{config_for_selections, write_config};
97use extract::stream_and_extract;
98use eyre::Result;
99use manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest};
100use planning::{collect_planned_archives, summarize_download_startup};
101use progress::{DownloadProgress, DownloadRequestLimiter};
102use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks, MAINNET};
103use reth_cli::chainspec::ChainSpecParser;
104use reth_cli_util::cancellation::CancellationToken;
105use reth_db::{init_db, Database};
106use reth_db_api::transaction::DbTx;
107use reth_fs_util as fs;
108use reth_node_core::args::DefaultPruningValues;
109use reth_prune_types::PruneMode;
110use source::{
111 discover_manifest_url, fetch_manifest_from_source, fetch_snapshot_api_entries,
112 print_snapshot_listing, resolve_manifest_base_url,
113};
114use std::{
115 borrow::Cow,
116 collections::BTreeMap,
117 path::{Path, PathBuf},
118 sync::{Arc, OnceLock},
119};
120use tracing::info;
121use tui::{run_selector, SelectorOutput};
122
123const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
124const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
125const RETH_SNAPSHOTS_SOURCE: &str = "https://snapshots.reth.rs (default)";
126const SNAPSHOT_API_PATH: &str = "/api/snapshots";
127const FORCE_REMOVED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"];
128
129const MAX_CONCURRENT_DOWNLOADS: usize = 8;
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub(crate) enum SelectionPreset {
135 Minimal,
137 Full,
139 Archive,
141}
142
143struct ResolvedComponents {
144 selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
145 preset: Option<SelectionPreset>,
146}
147
148static DOWNLOAD_DEFAULTS: OnceLock<DownloadDefaults> = OnceLock::new();
150
151#[derive(Debug, Clone)]
155pub struct DownloadDefaults {
156 pub available_snapshots: Vec<Cow<'static, str>>,
158 pub default_base_url: Cow<'static, str>,
160 pub default_chain_aware_base_url: Option<Cow<'static, str>>,
168 pub snapshot_api_url: Cow<'static, str>,
172 pub long_help: Option<String>,
174}
175
176impl DownloadDefaults {
177 pub fn try_init(self) -> Result<(), Self> {
179 DOWNLOAD_DEFAULTS.set(self)
180 }
181
182 pub fn get_global() -> &'static DownloadDefaults {
184 DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
185 }
186
187 pub fn default_download_defaults() -> Self {
189 Self {
190 available_snapshots: vec![
191 Cow::Borrowed(RETH_SNAPSHOTS_SOURCE),
192 Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
193 ],
194 default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
195 default_chain_aware_base_url: None,
196 snapshot_api_url: Cow::Borrowed(RETH_SNAPSHOTS_API_URL),
197 long_help: None,
198 }
199 }
200
201 pub fn long_help(&self) -> String {
206 if let Some(ref custom_help) = self.long_help {
207 return custom_help.clone();
208 }
209
210 let implicit_download_help = if self.mainnet_only_discovery() {
211 "\nIf no URL is provided, the latest archive snapshot will only be proposed\nfor Ethereum mainnet. For other chains, provide --manifest-url, --manifest-path,\nor -u explicitly."
212 } else {
213 "\nIf no URL is provided, the latest archive snapshot for the selected chain\nwill be proposed for download from "
214 };
215
216 let mut help = format!(
217 "Specify a snapshot URL or let the command propose a default one.\n\n\
218 Browse available snapshots at {}\n\
219 or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
220 self.snapshot_source_url(),
221 );
222
223 for source in &self.available_snapshots {
224 help.push_str("- ");
225 help.push_str(source);
226 help.push('\n');
227 }
228
229 help.push_str(implicit_download_help);
230 if !self.mainnet_only_discovery() {
231 help.push_str(
232 self.default_chain_aware_base_url.as_deref().unwrap_or(&self.default_base_url),
233 );
234 help.push('.');
235 }
236 help.push_str(
237 "\n\nLocal file:// URLs are also supported for extracting snapshots from disk.",
238 );
239 help
240 }
241
242 fn mainnet_only_discovery(&self) -> bool {
243 self.snapshot_api_url.trim_end_matches('/') == RETH_SNAPSHOTS_API_URL
244 }
245
246 fn snapshot_source_url(&self) -> &str {
247 snapshot_source_url_from_api(&self.snapshot_api_url)
248 }
249
250 pub fn with_snapshot(mut self, source: impl Into<Cow<'static, str>>) -> Self {
252 self.available_snapshots.push(source.into());
253 self
254 }
255
256 pub fn with_snapshots(mut self, sources: Vec<Cow<'static, str>>) -> Self {
258 self.available_snapshots = sources;
259 self
260 }
261
262 pub fn with_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
264 self.default_base_url = url.into();
265 self
266 }
267
268 pub fn with_chain_aware_base_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
270 self.default_chain_aware_base_url = Some(url.into());
271 self
272 }
273
274 pub fn with_snapshot_api_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
279 self.snapshot_api_url = url.into();
280
281 let source_url = self.snapshot_source_url().to_string();
282 if self.default_chain_aware_base_url.is_none() {
283 self.default_chain_aware_base_url = Some(Cow::Owned(source_url.clone()));
284 }
285 for source in &mut self.available_snapshots {
286 if source.as_ref() == RETH_SNAPSHOTS_SOURCE {
287 *source = Cow::Owned(format!("{source_url} (default)"));
288 }
289 }
290
291 self
292 }
293
294 pub fn with_snapshot_source_url(mut self, url: impl Into<Cow<'static, str>>) -> Self {
299 let source_url = normalize_snapshot_source_url(url.into());
300 self.available_snapshots = vec![Cow::Owned(format!("{} (default)", source_url.as_ref()))];
301 self.default_base_url = source_url.clone();
302 self.default_chain_aware_base_url = Some(source_url.clone());
303 self.snapshot_api_url = Cow::Owned(format!("{}{SNAPSHOT_API_PATH}", source_url.as_ref()));
304 self
305 }
306
307 pub fn with_long_help(mut self, help: impl Into<String>) -> Self {
309 self.long_help = Some(help.into());
310 self
311 }
312}
313
314fn snapshot_source_url_from_api(api_url: &str) -> &str {
315 api_url.trim_end_matches('/').trim_end_matches(SNAPSHOT_API_PATH)
316}
317
318fn normalize_snapshot_source_url(url: Cow<'static, str>) -> Cow<'static, str> {
319 match url {
320 Cow::Borrowed(url) => Cow::Borrowed(snapshot_source_url_from_api(url)),
321 Cow::Owned(url) => Cow::Owned(snapshot_source_url_from_api(&url).to_string()),
322 }
323}
324
325impl Default for DownloadDefaults {
326 fn default() -> Self {
328 Self::default_download_defaults()
329 }
330}
331
332#[derive(Debug, Parser)]
334pub struct DownloadCommand<C: ChainSpecParser> {
335 #[command(flatten)]
336 env: EnvironmentArgs<C>,
337
338 #[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
343 url: Option<String>,
344
345 #[arg(long, value_name = "URL", conflicts_with = "url")]
350 manifest_url: Option<String>,
351
352 #[arg(long, value_name = "PATH", conflicts_with_all = ["url", "manifest_url"])]
354 manifest_path: Option<PathBuf>,
355
356 #[arg(long, conflicts_with_all = ["with_txs_since", "with_txs_distance", "minimal", "full", "archive"])]
358 with_txs: bool,
359
360 #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_txs", "with_txs_distance", "minimal", "full", "archive"])]
362 with_txs_since: Option<u64>,
363
364 #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_txs", "with_txs_since", "minimal", "full", "archive"])]
366 with_txs_distance: Option<u64>,
367
368 #[arg(long, conflicts_with_all = ["with_receipts_since", "with_receipts_distance", "minimal", "full", "archive"])]
370 with_receipts: bool,
371
372 #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_receipts", "with_receipts_distance", "minimal", "full", "archive"])]
374 with_receipts_since: Option<u64>,
375
376 #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_receipts", "with_receipts_since", "minimal", "full", "archive"])]
378 with_receipts_distance: Option<u64>,
379
380 #[arg(long, alias = "with-changesets", conflicts_with_all = ["with_state_history_since", "with_state_history_distance", "minimal", "full", "archive"])]
382 with_state_history: bool,
383
384 #[arg(long, value_name = "BLOCK_NUMBER", conflicts_with_all = ["with_state_history", "with_state_history_distance", "minimal", "full", "archive"])]
386 with_state_history_since: Option<u64>,
387
388 #[arg(long, value_name = "BLOCKS", value_parser = RangedU64ValueParser::<u64>::new().range(1..), conflicts_with_all = ["with_state_history", "with_state_history_since", "minimal", "full", "archive"])]
390 with_state_history_distance: Option<u64>,
391
392 #[arg(long, requires = "with_txs", conflicts_with_all = ["minimal", "full", "archive"])]
394 with_senders: bool,
395
396 #[arg(long, conflicts_with_all = ["minimal", "full", "archive", "without_rocksdb"])]
398 with_rocksdb: bool,
399
400 #[arg(long, alias = "all", conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "minimal", "full"])]
402 archive: bool,
403
404 #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "full"])]
406 minimal: bool,
407
408 #[arg(long, conflicts_with_all = ["with_txs", "with_txs_since", "with_txs_distance", "with_receipts", "with_receipts_since", "with_receipts_distance", "with_state_history", "with_state_history_since", "with_state_history_distance", "with_senders", "with_rocksdb", "archive", "minimal"])]
410 full: bool,
411
412 #[arg(long, conflicts_with_all = ["url", "with_rocksdb"])]
416 without_rocksdb: bool,
417
418 #[arg(long, short = 'y')]
421 non_interactive: bool,
422
423 #[arg(long, conflicts_with = "list")]
425 force: bool,
426
427 #[arg(long, default_value_t = true, num_args = 0..=1, default_missing_value = "true")]
434 resumable: bool,
435
436 #[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
441 download_concurrency: usize,
442
443 #[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
448 list: bool,
449}
450
451impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
452 pub async fn execute<N>(self) -> Result<()> {
454 let chain = self.env.chain.chain();
455 let chain_id = chain.id();
456
457 if self.list {
459 let entries = fetch_snapshot_api_entries(chain_id).await?;
460 print_snapshot_listing(&entries, chain_id);
461 return Ok(());
462 }
463
464 let data_dir = self.env.datadir.clone().resolve_datadir(chain);
465
466 let cancel_token = CancellationToken::new();
467 let _cancel_guard = cancel_token.drop_guard();
468
469 if let Some(ref url) = self.url {
471 let target_dir = data_dir.data_dir();
472 if self.force {
473 clear_existing_datadir(target_dir)?;
474 }
475 fs::create_dir_all(target_dir)?;
476
477 let request_limiter = DownloadRequestLimiter::new(self.download_concurrency.max(1));
478 info!(target: "reth::cli",
479 dir = ?data_dir.data_dir(),
480 url = %url,
481 "Starting snapshot download and extraction"
482 );
483
484 stream_and_extract(
485 url,
486 data_dir.data_dir(),
487 None,
488 self.resumable,
489 Some(request_limiter),
490 cancel_token.clone(),
491 )
492 .await?;
493 info!(target: "reth::cli", "Snapshot downloaded and extracted successfully");
494
495 return Ok(());
496 }
497
498 let manifest = self.load_manifest(chain_id).await?;
499 let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?;
500
501 if matches!(preset, Some(SelectionPreset::Archive)) {
502 inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb);
503 }
504
505 let target_dir = data_dir.data_dir();
506 let planned_downloads = collect_planned_archives(&manifest, &selections)?;
507 if self.force {
508 clear_existing_datadir(target_dir)?;
509 }
510 fs::create_dir_all(target_dir)?;
511 let startup_summary = summarize_download_startup(&planned_downloads.archives, target_dir)?;
512 info!(target: "reth::cli",
513 reusable = startup_summary.reusable,
514 needs_download = startup_summary.needs_download,
515 "Startup integrity summary (plain output files)"
516 );
517
518 info!(target: "reth::cli",
519 archives = planned_downloads.total_archives(),
520 download_total = %DownloadProgress::format_size(planned_downloads.total_download_size),
521 output_total = %DownloadProgress::format_size(planned_downloads.total_output_size),
522 "Downloading all archives"
523 );
524
525 run_modular_downloads(
526 planned_downloads,
527 target_dir,
528 self.download_concurrency.max(1),
529 cancel_token.clone(),
530 )
531 .await?;
532
533 self.finalize_modular_download(&selections, &manifest, preset, target_dir, &data_dir.db())?;
534
535 Ok(())
536 }
537
538 async fn load_manifest(&self, chain_id: u64) -> Result<SnapshotManifest> {
540 let manifest_source = self.resolve_manifest_source(chain_id).await?;
541
542 info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
543 let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
544 manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?);
545
546 info!(target: "reth::cli",
547 block = manifest.block,
548 chain_id = manifest.chain_id,
549 storage_version = %manifest.storage_version,
550 components = manifest.components.len(),
551 "Loaded snapshot manifest"
552 );
553
554 Ok(manifest)
555 }
556
557 fn finalize_modular_download(
559 &self,
560 selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
561 manifest: &SnapshotManifest,
562 preset: Option<SelectionPreset>,
563 target_dir: &Path,
564 db_path: &Path,
565 ) -> Result<()> {
566 let config =
567 config_for_selections(selections, manifest, preset, Some(self.env.chain.as_ref()));
568 if write_config(&config, target_dir)? {
569 let desc = config_gen::describe_prune_config(&config);
570 info!(target: "reth::cli", "{}", desc.join(", "));
571 }
572
573 let db = init_db(db_path, self.env.db.database_args())?;
574 let should_write_prune = config.prune.segments != Default::default();
575 let should_reset_indices = should_reset_index_stage_checkpoints(selections);
576 if should_write_prune || should_reset_indices {
577 let tx = db.tx_mut()?;
578
579 if should_write_prune {
580 config_gen::write_prune_checkpoints_tx(&tx, &config, manifest.block)?;
581 }
582
583 if should_reset_indices {
584 config_gen::reset_index_stage_checkpoints_tx(&tx)?;
585 }
586
587 tx.commit()?;
588 }
589
590 let start_command = startup_node_command::<C>(self.env.chain.as_ref());
591 info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command);
592
593 Ok(())
594 }
595
596 fn resolve_components(&self, manifest: &SnapshotManifest) -> Result<ResolvedComponents> {
598 let available = |ty: SnapshotComponentType| manifest.component(ty).is_some();
599
600 if self.archive {
602 return Ok(ResolvedComponents {
603 selections: SnapshotComponentType::ALL
604 .iter()
605 .copied()
606 .filter(|ty| available(*ty))
607 .filter(|ty| {
608 !self.without_rocksdb || *ty != SnapshotComponentType::RocksdbIndices
609 })
610 .map(|ty| (ty, ComponentSelection::All))
611 .collect(),
612 preset: Some(SelectionPreset::Archive),
613 });
614 }
615
616 if self.full {
617 return Ok(ResolvedComponents {
618 selections: self.full_preset_selections(manifest),
619 preset: Some(SelectionPreset::Full),
620 });
621 }
622
623 if self.minimal {
624 return Ok(ResolvedComponents {
625 selections: self.minimal_preset_selections(manifest),
626 preset: Some(SelectionPreset::Minimal),
627 });
628 }
629
630 let has_explicit_flags = self.with_txs ||
631 self.with_txs_since.is_some() ||
632 self.with_txs_distance.is_some() ||
633 self.with_receipts ||
634 self.with_receipts_since.is_some() ||
635 self.with_receipts_distance.is_some() ||
636 self.with_state_history ||
637 self.with_state_history_since.is_some() ||
638 self.with_state_history_distance.is_some() ||
639 self.with_senders ||
640 self.with_rocksdb;
641
642 if has_explicit_flags {
643 let mut selections = BTreeMap::new();
644 let tx_selection = explicit_component_selection(
645 self.with_txs,
646 self.with_txs_since,
647 self.with_txs_distance,
648 manifest.block,
649 );
650 let receipt_selection = explicit_component_selection(
651 self.with_receipts,
652 self.with_receipts_since,
653 self.with_receipts_distance,
654 manifest.block,
655 );
656 let state_history_selection = explicit_component_selection(
657 self.with_state_history,
658 self.with_state_history_since,
659 self.with_state_history_distance,
660 manifest.block,
661 );
662
663 if available(SnapshotComponentType::State) {
665 selections.insert(SnapshotComponentType::State, ComponentSelection::All);
666 }
667 if available(SnapshotComponentType::Headers) {
668 selections.insert(SnapshotComponentType::Headers, ComponentSelection::All);
669 }
670 if let Some(selection) = tx_selection &&
671 available(SnapshotComponentType::Transactions)
672 {
673 selections.insert(SnapshotComponentType::Transactions, selection);
674 }
675 if let Some(selection) = receipt_selection &&
676 available(SnapshotComponentType::Receipts)
677 {
678 selections.insert(SnapshotComponentType::Receipts, selection);
679 }
680 if let Some(selection) = state_history_selection {
681 if available(SnapshotComponentType::AccountChangesets) {
682 selections.insert(SnapshotComponentType::AccountChangesets, selection);
683 }
684 if available(SnapshotComponentType::StorageChangesets) {
685 selections.insert(SnapshotComponentType::StorageChangesets, selection);
686 }
687 }
688 if self.with_senders && available(SnapshotComponentType::TransactionSenders) {
689 selections
690 .insert(SnapshotComponentType::TransactionSenders, ComponentSelection::All);
691 }
692 if self.with_rocksdb && available(SnapshotComponentType::RocksdbIndices) {
693 selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
694 }
695 return Ok(ResolvedComponents { selections, preset: None });
696 }
697
698 if self.non_interactive {
699 return Ok(ResolvedComponents {
700 selections: self.minimal_preset_selections(manifest),
701 preset: Some(SelectionPreset::Minimal),
702 });
703 }
704
705 let full_preset = self.full_preset_selections(manifest);
707 let SelectorOutput { selections, preset } = run_selector(manifest.clone(), &full_preset)?;
708 let selected =
709 selections.into_iter().filter(|(_, sel)| *sel != ComponentSelection::None).collect();
710
711 Ok(ResolvedComponents { selections: selected, preset })
712 }
713
714 fn minimal_preset_selections(
716 &self,
717 manifest: &SnapshotManifest,
718 ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
719 SnapshotComponentType::ALL
720 .iter()
721 .copied()
722 .filter(|ty| manifest.component(*ty).is_some())
723 .map(|ty| (ty, ty.minimal_selection()))
724 .collect()
725 }
726
727 fn full_preset_selections(
729 &self,
730 manifest: &SnapshotManifest,
731 ) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
732 let mut selections = BTreeMap::new();
733
734 for ty in [
735 SnapshotComponentType::State,
736 SnapshotComponentType::Headers,
737 SnapshotComponentType::Transactions,
738 SnapshotComponentType::Receipts,
739 SnapshotComponentType::AccountChangesets,
740 SnapshotComponentType::StorageChangesets,
741 SnapshotComponentType::TransactionSenders,
742 SnapshotComponentType::RocksdbIndices,
743 ] {
744 if manifest.component(ty).is_none() {
745 continue;
746 }
747
748 let selection = self.full_selection_for_component(ty, manifest.block);
749 if selection != ComponentSelection::None {
750 selections.insert(ty, selection);
751 }
752 }
753
754 selections
755 }
756
757 fn full_selection_for_component(
759 &self,
760 ty: SnapshotComponentType,
761 snapshot_block: u64,
762 ) -> ComponentSelection {
763 let defaults = DefaultPruningValues::get_global();
764 match ty {
765 SnapshotComponentType::State | SnapshotComponentType::Headers => {
766 ComponentSelection::All
767 }
768 SnapshotComponentType::Transactions => {
769 if defaults.full_bodies_history_use_pre_merge {
770 match self
771 .env
772 .chain
773 .ethereum_fork_activation(EthereumHardfork::Paris)
774 .block_number()
775 {
776 Some(paris) if snapshot_block >= paris => ComponentSelection::Since(paris),
777 Some(_) => ComponentSelection::None,
778 None => ComponentSelection::All,
779 }
780 } else {
781 selection_from_prune_mode(
782 defaults.full_prune_modes.bodies_history,
783 snapshot_block,
784 )
785 }
786 }
787 SnapshotComponentType::Receipts => {
788 selection_from_prune_mode(defaults.full_prune_modes.receipts, snapshot_block)
789 }
790 SnapshotComponentType::AccountChangesets => {
791 selection_from_prune_mode(defaults.full_prune_modes.account_history, snapshot_block)
792 }
793 SnapshotComponentType::StorageChangesets => {
794 selection_from_prune_mode(defaults.full_prune_modes.storage_history, snapshot_block)
795 }
796 SnapshotComponentType::TransactionSenders => {
797 selection_from_prune_mode(defaults.full_prune_modes.sender_recovery, snapshot_block)
798 }
799 SnapshotComponentType::RocksdbIndices => ComponentSelection::None,
801 }
802 }
803
804 async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
806 if let Some(path) = &self.manifest_path {
807 return Ok(path.display().to_string());
808 }
809
810 match &self.manifest_url {
811 Some(url) => Ok(url.clone()),
812 None => {
813 let defaults = DownloadDefaults::get_global();
814 if defaults.mainnet_only_discovery() && chain_id != MAINNET.chain.id() {
815 eyre::bail!(
816 "Snapshots are only auto-discovered for Ethereum mainnet.\n\n\
817 Chain {chain_id} requires an explicit source:\n\
818 \t--manifest-url <URL>\n\
819 \t--manifest-path <PATH>\n\
820 \t-u <SNAPSHOT-URL>\n\n\
821 Use --list to inspect snapshots exposed by {}.",
822 defaults.snapshot_source_url(),
823 );
824 }
825
826 discover_manifest_url(chain_id).await
827 }
828 }
829 }
830}
831
832fn explicit_component_selection(
835 all: bool,
836 since: Option<u64>,
837 distance: Option<u64>,
838 snapshot_block: u64,
839) -> Option<ComponentSelection> {
840 if all {
841 Some(ComponentSelection::All)
842 } else if let Some(block) = since {
843 (block <= snapshot_block).then_some(ComponentSelection::Since(block))
844 } else {
845 distance.map(ComponentSelection::Distance)
846 }
847}
848
849fn selection_from_prune_mode(mode: Option<PruneMode>, snapshot_block: u64) -> ComponentSelection {
851 match mode {
852 None => ComponentSelection::All,
853 Some(PruneMode::Full) => ComponentSelection::None,
854 Some(PruneMode::Distance(d)) => ComponentSelection::Distance(d),
855 Some(PruneMode::Before(block)) => {
856 if snapshot_block >= block {
857 ComponentSelection::Since(block)
858 } else {
859 ComponentSelection::None
860 }
861 }
862 }
863}
864
865fn clear_existing_datadir(target_dir: &Path) -> Result<()> {
867 if !target_dir.try_exists()? {
868 return Ok(());
869 }
870
871 info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data");
872 for entry in FORCE_REMOVED_DATADIR_PATHS {
873 let path = target_dir.join(entry);
874 if !path.try_exists()? {
875 continue;
876 }
877
878 let metadata = fs::metadata(&path)?;
879 if metadata.is_dir() {
880 fs::remove_dir_all(&path)?;
881 } else if metadata.is_file() {
882 fs::remove_file(&path)?;
883 }
884 }
885
886 Ok(())
887}
888
889fn inject_archive_only_components(
892 selections: &mut BTreeMap<SnapshotComponentType, ComponentSelection>,
893 manifest: &SnapshotManifest,
894 include_rocksdb: bool,
895) {
896 let is_all =
897 |ty: SnapshotComponentType| selections.get(&ty).copied() == Some(ComponentSelection::All);
898
899 let is_archive = is_all(SnapshotComponentType::Transactions) &&
900 is_all(SnapshotComponentType::Receipts) &&
901 is_all(SnapshotComponentType::AccountChangesets) &&
902 is_all(SnapshotComponentType::StorageChangesets);
903
904 if !is_archive {
905 return;
906 }
907
908 for component in
909 [SnapshotComponentType::TransactionSenders, SnapshotComponentType::RocksdbIndices]
910 {
911 if component == SnapshotComponentType::RocksdbIndices && !include_rocksdb {
912 continue;
913 }
914
915 if manifest.component(component).is_some() {
916 selections.insert(component, ComponentSelection::All);
917 }
918 }
919}
920
921fn should_reset_index_stage_checkpoints(
923 selections: &BTreeMap<SnapshotComponentType, ComponentSelection>,
924) -> bool {
925 !matches!(selections.get(&SnapshotComponentType::RocksdbIndices), Some(ComponentSelection::All))
926}
927
928fn startup_node_command<C>(chain_spec: &C::ChainSpec) -> String
929where
930 C: ChainSpecParser,
931 C::ChainSpec: EthChainSpec,
932{
933 startup_node_command_for_binary::<C>(¤t_binary_name(), chain_spec)
934}
935
936fn startup_node_command_for_binary<C>(binary_name: &str, chain_spec: &C::ChainSpec) -> String
937where
938 C: ChainSpecParser,
939 C::ChainSpec: EthChainSpec,
940{
941 let mut command = format!("{binary_name} node");
942
943 if let Some(chain_arg) = startup_chain_arg::<C>(chain_spec) {
944 command.push_str(" --chain ");
945 command.push_str(&chain_arg);
946 }
947
948 command
949}
950
951fn current_binary_name() -> String {
952 std::env::args_os()
953 .next()
954 .map(PathBuf::from)
955 .and_then(|path| path.file_stem().map(|name| name.to_owned()))
956 .and_then(|name| name.into_string().ok())
957 .filter(|name| !name.is_empty())
958 .unwrap_or_else(|| "reth".to_string())
959}
960
961fn download_command() -> String {
962 download_command_for_binary(¤t_binary_name())
963}
964
965fn download_command_for_binary(binary_name: &str) -> String {
966 format!("{binary_name} download")
967}
968
969fn startup_chain_arg<C>(chain_spec: &C::ChainSpec) -> Option<String>
970where
971 C: ChainSpecParser,
972 C::ChainSpec: EthChainSpec,
973{
974 let current_chain = chain_spec.chain();
975 let current_genesis_hash = chain_spec.genesis_hash();
976 let default_chain = C::default_value().and_then(|chain_name| C::parse(chain_name).ok());
977
978 if default_chain.as_ref().is_some_and(|default_chain| {
979 default_chain.chain() == current_chain &&
980 default_chain.genesis_hash() == current_genesis_hash
981 }) {
982 return None;
983 }
984
985 C::SUPPORTED_CHAINS
986 .iter()
987 .find_map(|chain_name| {
988 let parsed_chain = C::parse(chain_name).ok()?;
989 (parsed_chain.chain() == current_chain &&
990 parsed_chain.genesis_hash() == current_genesis_hash)
991 .then(|| (*chain_name).to_string())
992 })
993 .or_else(|| Some("<chain-or-chainspec>".to_string()))
994}
995
996impl<C: ChainSpecParser> DownloadCommand<C> {
997 pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
999 Some(&self.env.chain)
1000 }
1001}
1002
1003const MAX_DOWNLOAD_RETRIES: u32 = 10;
1004const RETRY_BACKOFF_SECS: u64 = 5;
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use clap::{Args, Parser};
1010 use extract::CompressionFormat;
1011 use manifest::{ComponentManifest, SingleArchive};
1012 use reth_chainspec::{HOLESKY, MAINNET};
1013 use reth_ethereum_cli::chainspec::EthereumChainSpecParser;
1014
1015 #[derive(Parser)]
1016 struct CommandParser<T: Args> {
1017 #[command(flatten)]
1018 args: T,
1019 }
1020
1021 fn manifest_with_archive_only_components() -> SnapshotManifest {
1022 let mut components = BTreeMap::new();
1023 components.insert(
1024 SnapshotComponentType::TransactionSenders.key().to_string(),
1025 ComponentManifest::Single(SingleArchive {
1026 file: "transaction_senders.tar.zst".to_string(),
1027 size: 1,
1028 decompressed_size: 0,
1029 blake3: None,
1030 output_files: vec![],
1031 }),
1032 );
1033 components.insert(
1034 SnapshotComponentType::RocksdbIndices.key().to_string(),
1035 ComponentManifest::Single(SingleArchive {
1036 file: "rocksdb_indices.tar.zst".to_string(),
1037 size: 1,
1038 decompressed_size: 0,
1039 blake3: None,
1040 output_files: vec![],
1041 }),
1042 );
1043 SnapshotManifest {
1044 block: 0,
1045 chain_id: 1,
1046 storage_version: 2,
1047 timestamp: 0,
1048 base_url: Some("https://example.com".to_string()),
1049 reth_version: None,
1050 components,
1051 }
1052 }
1053
1054 #[test]
1055 fn test_download_defaults_builder() {
1056 let defaults = DownloadDefaults::default()
1057 .with_snapshot("https://example.com/snapshots (example)")
1058 .with_base_url("https://example.com");
1059
1060 assert_eq!(defaults.default_base_url, "https://example.com");
1061 assert_eq!(defaults.available_snapshots.len(), 3); }
1063
1064 #[test]
1065 fn test_download_defaults_replace_snapshots() {
1066 let defaults = DownloadDefaults::default().with_snapshots(vec![
1067 Cow::Borrowed("https://custom1.com"),
1068 Cow::Borrowed("https://custom2.com"),
1069 ]);
1070
1071 assert_eq!(defaults.available_snapshots.len(), 2);
1072 assert_eq!(defaults.available_snapshots[0], "https://custom1.com");
1073 }
1074
1075 #[test]
1076 fn test_long_help_generation() {
1077 let defaults = DownloadDefaults::default();
1078 let help = defaults.long_help();
1079
1080 assert!(help.contains("Available snapshot sources:"));
1081 assert!(help.contains("Ethereum mainnet"));
1082 assert!(help.contains("snapshots.reth.rs"));
1083 assert!(help.contains("publicnode.com"));
1084 assert!(help.contains("file://"));
1085 }
1086
1087 #[test]
1088 fn test_custom_snapshot_api_keeps_selected_chain_help() {
1089 let defaults = DownloadDefaults::default()
1090 .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots");
1091 let help = defaults.long_help();
1092
1093 assert_eq!(
1094 defaults.default_chain_aware_base_url.as_deref(),
1095 Some("https://snapshots.tempoxyz.dev")
1096 );
1097 assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1098 assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1099 assert!(help.contains("selected chain"));
1100 assert!(!help.contains("Ethereum mainnet"));
1101 assert!(!help.contains("snapshots.reth.rs"));
1102 }
1103
1104 #[test]
1105 fn test_snapshot_source_url_sets_generated_references() {
1106 let defaults =
1107 DownloadDefaults::default().with_snapshot_source_url("https://snapshots.tempoxyz.dev/");
1108 let help = defaults.long_help();
1109
1110 assert_eq!(defaults.snapshot_api_url, "https://snapshots.tempoxyz.dev/api/snapshots");
1111 assert_eq!(defaults.default_base_url, "https://snapshots.tempoxyz.dev");
1112 assert_eq!(
1113 defaults.default_chain_aware_base_url.as_deref(),
1114 Some("https://snapshots.tempoxyz.dev")
1115 );
1116 assert_eq!(
1117 defaults.available_snapshots.iter().map(|source| source.as_ref()).collect::<Vec<_>>(),
1118 vec!["https://snapshots.tempoxyz.dev (default)"]
1119 );
1120 assert!(!defaults.mainnet_only_discovery());
1121 assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1122 assert!(help.contains("from https://snapshots.tempoxyz.dev."));
1123 }
1124
1125 #[test]
1126 fn test_snapshot_api_url_trailing_slash_sets_source_url() {
1127 let defaults = DownloadDefaults::default()
1128 .with_snapshot_api_url("https://snapshots.tempoxyz.dev/api/snapshots/");
1129 let help = defaults.long_help();
1130
1131 assert_eq!(
1132 defaults.default_chain_aware_base_url.as_deref(),
1133 Some("https://snapshots.tempoxyz.dev")
1134 );
1135 assert!(help.contains("Browse available snapshots at https://snapshots.tempoxyz.dev"));
1136 assert!(help.contains("- https://snapshots.tempoxyz.dev (default)"));
1137 }
1138
1139 #[test]
1140 fn test_long_help_override() {
1141 let custom_help = "This is custom help text for downloading snapshots.";
1142 let defaults = DownloadDefaults::default().with_long_help(custom_help);
1143
1144 let help = defaults.long_help();
1145 assert_eq!(help, custom_help);
1146 assert!(!help.contains("Available snapshot sources:"));
1147 }
1148
1149 #[test]
1150 fn test_builder_chaining() {
1151 let defaults = DownloadDefaults::default()
1152 .with_base_url("https://custom.example.com")
1153 .with_snapshot("https://snapshot1.com")
1154 .with_snapshot("https://snapshot2.com")
1155 .with_long_help("Custom help for snapshots");
1156
1157 assert_eq!(defaults.default_base_url, "https://custom.example.com");
1158 assert_eq!(defaults.available_snapshots.len(), 4); assert_eq!(defaults.long_help, Some("Custom help for snapshots".to_string()));
1160 }
1161
1162 #[test]
1163 fn test_download_resumable_defaults_to_true() {
1164 let args =
1165 CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from(["reth"]).args;
1166
1167 assert!(args.resumable);
1168 }
1169
1170 #[test]
1171 fn test_download_resumable_implicit_true() {
1172 let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1173 "reth",
1174 "--resumable",
1175 ])
1176 .args;
1177
1178 assert!(args.resumable);
1179 }
1180
1181 #[test]
1182 fn test_download_resumable_explicit_false() {
1183 let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1184 "reth",
1185 "--resumable=false",
1186 ])
1187 .args;
1188
1189 assert!(!args.resumable);
1190 }
1191
1192 #[test]
1193 fn resolve_manifest_source_requires_explicit_source_for_non_mainnet_defaults() {
1194 let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1195 "reth", "--chain", "holesky",
1196 ])
1197 .args;
1198
1199 let err = tokio::runtime::Runtime::new()
1200 .unwrap()
1201 .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1202 .unwrap_err();
1203
1204 let message = err.to_string();
1205 assert!(message.contains("only auto-discovered for Ethereum mainnet"));
1206 assert!(message.contains("--manifest-url <URL>"));
1207 assert!(message.contains("-u <SNAPSHOT-URL>"));
1208 }
1209
1210 #[test]
1211 fn resolve_manifest_source_allows_manifest_path_for_non_mainnet_defaults() {
1212 let args = CommandParser::<DownloadCommand<EthereumChainSpecParser>>::parse_from([
1213 "reth",
1214 "--chain",
1215 "holesky",
1216 "--manifest-path",
1217 "./manifest.json",
1218 ])
1219 .args;
1220
1221 let source = tokio::runtime::Runtime::new()
1222 .unwrap()
1223 .block_on(args.resolve_manifest_source(HOLESKY.chain.id()))
1224 .unwrap();
1225
1226 assert_eq!(source, "./manifest.json");
1227 }
1228
1229 #[test]
1230 fn test_compression_format_detection() {
1231 assert!(matches!(
1232 CompressionFormat::from_url("https://example.com/snapshot.tar.lz4"),
1233 Ok(CompressionFormat::Lz4)
1234 ));
1235 assert!(matches!(
1236 CompressionFormat::from_url("https://example.com/snapshot.tar.zst"),
1237 Ok(CompressionFormat::Zstd)
1238 ));
1239 assert!(matches!(
1240 CompressionFormat::from_url("file:///path/to/snapshot.tar.lz4"),
1241 Ok(CompressionFormat::Lz4)
1242 ));
1243 assert!(matches!(
1244 CompressionFormat::from_url("file:///path/to/snapshot.tar.zst"),
1245 Ok(CompressionFormat::Zstd)
1246 ));
1247 assert!(CompressionFormat::from_url("https://example.com/snapshot.tar.gz").is_err());
1248 }
1249
1250 #[test]
1251 fn inject_archive_only_components_for_archive_selection() {
1252 let manifest = manifest_with_archive_only_components();
1253 let mut selections = BTreeMap::new();
1254 selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1255 selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1256 selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1257 selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1258
1259 inject_archive_only_components(&mut selections, &manifest, true);
1260
1261 assert_eq!(
1262 selections.get(&SnapshotComponentType::TransactionSenders),
1263 Some(&ComponentSelection::All)
1264 );
1265 assert_eq!(
1266 selections.get(&SnapshotComponentType::RocksdbIndices),
1267 Some(&ComponentSelection::All)
1268 );
1269 }
1270
1271 #[test]
1272 fn inject_archive_only_components_without_rocksdb() {
1273 let manifest = manifest_with_archive_only_components();
1274 let mut selections = BTreeMap::new();
1275 selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1276 selections.insert(SnapshotComponentType::Receipts, ComponentSelection::All);
1277 selections.insert(SnapshotComponentType::AccountChangesets, ComponentSelection::All);
1278 selections.insert(SnapshotComponentType::StorageChangesets, ComponentSelection::All);
1279
1280 inject_archive_only_components(&mut selections, &manifest, false);
1281
1282 assert_eq!(
1283 selections.get(&SnapshotComponentType::TransactionSenders),
1284 Some(&ComponentSelection::All)
1285 );
1286 assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None);
1287 }
1288
1289 #[test]
1290 fn should_reset_index_stage_checkpoints_without_rocksdb_indices() {
1291 let mut selections = BTreeMap::new();
1292 selections.insert(SnapshotComponentType::Transactions, ComponentSelection::All);
1293 assert!(should_reset_index_stage_checkpoints(&selections));
1294
1295 selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All);
1296 assert!(!should_reset_index_stage_checkpoints(&selections));
1297 }
1298
1299 #[test]
1300 fn startup_node_command_omits_default_chain_arg() {
1301 let command =
1302 startup_node_command_for_binary::<EthereumChainSpecParser>("reth", MAINNET.as_ref());
1303
1304 assert_eq!(command, "reth node");
1305 }
1306
1307 #[test]
1308 fn startup_node_command_includes_non_default_chain_arg() {
1309 let command =
1310 startup_node_command_for_binary::<EthereumChainSpecParser>("reth", HOLESKY.as_ref());
1311
1312 assert_eq!(command, "reth node --chain holesky");
1313 }
1314
1315 #[test]
1316 fn startup_node_command_uses_running_binary_name() {
1317 let command =
1318 startup_node_command_for_binary::<EthereumChainSpecParser>("tempo", HOLESKY.as_ref());
1319
1320 assert_eq!(command, "tempo node --chain holesky");
1321 }
1322
1323 #[test]
1324 fn download_command_uses_binary_name() {
1325 assert_eq!(download_command_for_binary("tempo"), "tempo download");
1326 }
1327}