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