Skip to content

Commit 999fa06

Browse files
authored
feat(download): use snapshots.reth.rs API with --list and --channel flags (paradigmxyz#22859)
1 parent d6b1d06 commit 999fa06

2 files changed

Lines changed: 161 additions & 45 deletions

File tree

crates/cli/commands/src/download/mod.rs

Lines changed: 151 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ use url::Url;
4343
use zstd::stream::read::Decoder as ZstdDecoder;
4444

4545
const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
46-
const MERKLE_BASE_URL: &str = "https://downloads.merkle.io";
46+
const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs";
47+
const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots";
4748
const EXTENSION_TAR_LZ4: &str = ".tar.lz4";
4849
const EXTENSION_TAR_ZSTD: &str = ".tar.zst";
4950
const DOWNLOAD_CACHE_DIR: &str = ".download-cache";
@@ -98,14 +99,14 @@ impl DownloadDefaults {
9899
DOWNLOAD_DEFAULTS.get_or_init(DownloadDefaults::default_download_defaults)
99100
}
100101

101-
/// Default download configuration with defaults from merkle.io and publicnode
102+
/// Default download configuration with defaults from snapshots.reth.rs and publicnode
102103
pub fn default_download_defaults() -> Self {
103104
Self {
104105
available_snapshots: vec![
105-
Cow::Borrowed("https://www.merkle.io/snapshots (default, mainnet archive)"),
106+
Cow::Borrowed("https://snapshots.reth.rs (default)"),
106107
Cow::Borrowed("https://publicnode.com/snapshots (full nodes & testnets)"),
107108
],
108-
default_base_url: Cow::Borrowed(MERKLE_BASE_URL),
109+
default_base_url: Cow::Borrowed(RETH_SNAPSHOTS_BASE_URL),
109110
default_chain_aware_base_url: None,
110111
long_help: None,
111112
}
@@ -121,7 +122,9 @@ impl DownloadDefaults {
121122
}
122123

123124
let mut help = String::from(
124-
"Specify a snapshot URL or let the command propose a default one.\n\nAvailable snapshot sources:\n",
125+
"Specify a snapshot URL or let the command propose a default one.\n\n\
126+
Browse available snapshots at https://snapshots.reth.rs\n\
127+
or use --list-snapshots to see them from the CLI.\n\nAvailable snapshot sources:\n",
125128
);
126129

127130
for source in &self.available_snapshots {
@@ -188,6 +191,7 @@ pub struct DownloadCommand<C: ChainSpecParser> {
188191
/// Custom URL to download a single snapshot archive (legacy mode).
189192
///
190193
/// When provided, downloads and extracts a single archive without component selection.
194+
/// Browse available snapshots at <https://snapshots.reth.rs> or use --list-snapshots.
191195
#[arg(long, short, long_help = DownloadDefaults::get_global().long_help())]
192196
url: Option<String>,
193197

@@ -248,6 +252,13 @@ pub struct DownloadCommand<C: ChainSpecParser> {
248252
/// Maximum number of concurrent modular archive workers.
249253
#[arg(long, default_value_t = MAX_CONCURRENT_DOWNLOADS)]
250254
download_concurrency: usize,
255+
256+
/// List available snapshots from snapshots.reth.rs and exit.
257+
///
258+
/// Queries the snapshots API and prints all available snapshots for the selected chain,
259+
/// including block number, size, and manifest URL.
260+
#[arg(long, alias = "list-snapshots", conflicts_with_all = ["url", "manifest_url", "manifest_path"])]
261+
list: bool,
251262
}
252263

253264
impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCommand<C> {
@@ -260,16 +271,23 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
260271
let cancel_token = CancellationToken::new();
261272
let _cancel_guard = cancel_token.drop_guard();
262273

274+
// --list: print available snapshots and exit
275+
if self.list {
276+
let entries = fetch_snapshot_api_entries(chain_id).await?;
277+
print_snapshot_listing(&entries, chain_id);
278+
return Ok(());
279+
}
280+
263281
// Legacy single-URL mode: download one archive and extract it
264-
if let Some(url) = self.url {
282+
if let Some(ref url) = self.url {
265283
info!(target: "reth::cli",
266284
dir = ?data_dir.data_dir(),
267285
url = %url,
268286
"Starting snapshot download and extraction"
269287
);
270288

271289
stream_and_extract(
272-
&url,
290+
url,
273291
data_dir.data_dir(),
274292
None,
275293
self.resumable,
@@ -282,7 +300,7 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
282300
}
283301

284302
// Modular download: fetch manifest and select components
285-
let manifest_source = self.resolve_manifest_source(chain_id);
303+
let manifest_source = self.resolve_manifest_source(chain_id).await?;
286304

287305
info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest");
288306
let mut manifest = fetch_manifest_from_source(&manifest_source).await?;
@@ -621,17 +639,14 @@ impl<C: ChainSpecParser<ChainSpec: EthChainSpec + EthereumHardforks>> DownloadCo
621639
}
622640
}
623641

624-
fn resolve_manifest_source(&self, chain_id: u64) -> String {
642+
async fn resolve_manifest_source(&self, chain_id: u64) -> Result<String> {
625643
if let Some(path) = &self.manifest_path {
626-
return path.display().to_string();
644+
return Ok(path.display().to_string());
627645
}
628646

629647
match &self.manifest_url {
630-
Some(url) => url.clone(),
631-
None => {
632-
let base_url = get_base_url(chain_id);
633-
format!("{base_url}/manifest.json")
634-
}
648+
Some(url) => Ok(url.clone()),
649+
None => discover_manifest_url(chain_id).await,
635650
}
636651
}
637652
}
@@ -1572,13 +1587,126 @@ fn file_blake3_hex(path: &Path) -> Result<String> {
15721587
Ok(hasher.finalize().to_hex().to_string())
15731588
}
15741589

1575-
/// Builds the base URL for the given chain ID using configured defaults.
1576-
fn get_base_url(chain_id: u64) -> String {
1577-
let defaults = DownloadDefaults::get_global();
1578-
match &defaults.default_chain_aware_base_url {
1579-
Some(url) => format!("{url}/{chain_id}"),
1580-
None => defaults.default_base_url.to_string(),
1590+
/// Discovers the latest snapshot manifest URL for the given chain from the snapshots API.
1591+
///
1592+
/// Queries `snapshots.reth.rs/api/snapshots` and returns the manifest URL for the most
1593+
/// recent modular snapshot matching the requested chain.
1594+
async fn discover_manifest_url(chain_id: u64) -> Result<String> {
1595+
let api_url = RETH_SNAPSHOTS_API_URL;
1596+
1597+
info!(target: "reth::cli", %api_url, %chain_id, "Discovering latest snapshot manifest");
1598+
1599+
let entries = fetch_snapshot_api_entries(chain_id).await?;
1600+
1601+
let entry =
1602+
entries.iter().filter(|s| s.is_modular()).max_by_key(|s| s.block).ok_or_else(|| {
1603+
eyre::eyre!(
1604+
"No modular snapshot manifest found for chain \
1605+
{chain_id} at {api_url}\n\n\
1606+
You can provide a manifest URL directly with --manifest-url, or\n\
1607+
use a direct snapshot URL with -u from:\n\
1608+
\t- https://snapshots.reth.rs\n\n\
1609+
Use --list to see all available snapshots."
1610+
)
1611+
})?;
1612+
1613+
info!(target: "reth::cli",
1614+
block = entry.block,
1615+
url = %entry.metadata_url,
1616+
"Found latest snapshot manifest"
1617+
);
1618+
1619+
Ok(entry.metadata_url.clone())
1620+
}
1621+
1622+
/// Deserializes a JSON value that may be either a number or a string-encoded number.
1623+
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error>
1624+
where
1625+
D: serde::Deserializer<'de>,
1626+
{
1627+
use serde::Deserialize;
1628+
let value = serde_json::Value::deserialize(deserializer)?;
1629+
match &value {
1630+
serde_json::Value::Number(n) => {
1631+
n.as_u64().ok_or_else(|| serde::de::Error::custom("expected u64"))
1632+
}
1633+
serde_json::Value::String(s) => {
1634+
s.parse::<u64>().map_err(|_| serde::de::Error::custom("expected numeric string"))
1635+
}
1636+
_ => Err(serde::de::Error::custom("expected number or string")),
1637+
}
1638+
}
1639+
1640+
/// An entry from the `snapshots.reth.rs/api/snapshots` listing.
1641+
#[derive(serde::Deserialize)]
1642+
#[serde(rename_all = "camelCase")]
1643+
struct SnapshotApiEntry {
1644+
#[serde(deserialize_with = "deserialize_string_or_u64")]
1645+
chain_id: u64,
1646+
#[serde(deserialize_with = "deserialize_string_or_u64")]
1647+
block: u64,
1648+
#[serde(default)]
1649+
date: Option<String>,
1650+
#[serde(default)]
1651+
profile: Option<String>,
1652+
metadata_url: String,
1653+
#[serde(default)]
1654+
size: u64,
1655+
}
1656+
1657+
impl SnapshotApiEntry {
1658+
fn is_modular(&self) -> bool {
1659+
self.metadata_url.ends_with("manifest.json")
1660+
}
1661+
}
1662+
1663+
/// Fetches the full snapshot listing from the snapshots API, filtered by chain ID.
1664+
async fn fetch_snapshot_api_entries(chain_id: u64) -> Result<Vec<SnapshotApiEntry>> {
1665+
let api_url = RETH_SNAPSHOTS_API_URL;
1666+
1667+
let entries: Vec<SnapshotApiEntry> = Client::new()
1668+
.get(api_url)
1669+
.send()
1670+
.await
1671+
.and_then(|r| r.error_for_status())
1672+
.wrap_err_with(|| format!("Failed to fetch snapshot listing from {api_url}"))?
1673+
.json()
1674+
.await?;
1675+
1676+
Ok(entries.into_iter().filter(|e| e.chain_id == chain_id).collect())
1677+
}
1678+
1679+
/// Prints a formatted table of available modular snapshots.
1680+
fn print_snapshot_listing(entries: &[SnapshotApiEntry], chain_id: u64) {
1681+
let modular: Vec<_> = entries.iter().filter(|e| e.is_modular()).collect();
1682+
1683+
println!("Available snapshots for chain {chain_id} (https://snapshots.reth.rs):\n");
1684+
println!("{:<12} {:>10} {:<10} {:>10} MANIFEST URL", "DATE", "BLOCK", "PROFILE", "SIZE");
1685+
println!("{}", "-".repeat(100));
1686+
1687+
for entry in &modular {
1688+
let date = entry.date.as_deref().unwrap_or("-");
1689+
let profile = entry.profile.as_deref().unwrap_or("-");
1690+
let size = if entry.size > 0 {
1691+
DownloadProgress::format_size(entry.size)
1692+
} else {
1693+
"-".to_string()
1694+
};
1695+
1696+
println!(
1697+
"{date:<12} {:>10} {profile:<10} {size:>10} {}",
1698+
entry.block, entry.metadata_url
1699+
);
1700+
}
1701+
1702+
if modular.is_empty() {
1703+
println!(" (no modular snapshots found)");
15811704
}
1705+
1706+
println!(
1707+
"\nTo download a specific snapshot, copy its manifest URL and run:\n \
1708+
reth download --manifest-url <URL>"
1709+
);
15821710
}
15831711

15841712
async fn fetch_manifest_from_source(source: &str) -> Result<SnapshotManifest> {
@@ -1597,7 +1725,7 @@ async fn fetch_manifest_from_source(source: &str) -> Result<SnapshotManifest> {
15971725
You can use a direct snapshot URL instead:\n\n\
15981726
\treth download -u <snapshot-url>\n\n\
15991727
Available snapshot sources:\n\
1600-
\t- https://www.merkle.io/snapshots\n\
1728+
\t- https://snapshots.reth.rs\n\
16011729
\t- https://publicnode.com/snapshots"
16021730
)
16031731
})?;
@@ -1666,26 +1794,6 @@ fn resolve_manifest_base_url(manifest: &SnapshotManifest, source: &str) -> Resul
16661794
Ok(base)
16671795
}
16681796

1669-
/// Builds default URL for latest mainnet archive snapshot using configured defaults.
1670-
///
1671-
/// Used by the legacy single-archive download flow when no manifest is available.
1672-
#[allow(dead_code)]
1673-
async fn get_latest_snapshot_url(chain_id: u64) -> Result<String> {
1674-
let base_url = get_base_url(chain_id);
1675-
let latest_url = format!("{base_url}/latest.txt");
1676-
let filename = Client::new()
1677-
.get(latest_url)
1678-
.send()
1679-
.await?
1680-
.error_for_status()?
1681-
.text()
1682-
.await?
1683-
.trim()
1684-
.to_string();
1685-
1686-
Ok(format!("{base_url}/{filename}"))
1687-
}
1688-
16891797
#[cfg(test)]
16901798
mod tests {
16911799
use super::*;
@@ -1750,7 +1858,7 @@ mod tests {
17501858
let help = defaults.long_help();
17511859

17521860
assert!(help.contains("Available snapshot sources:"));
1753-
assert!(help.contains("merkle.io"));
1861+
assert!(help.contains("snapshots.reth.rs"));
17541862
assert!(help.contains("publicnode.com"));
17551863
assert!(help.contains("file://"));
17561864
}

docs/vocs/docs/pages/cli/reth/download.mdx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,15 @@ Storage:
115115
-u, --url <URL>
116116
Specify a snapshot URL or let the command propose a default one.
117117
118+
Browse available snapshots at https://snapshots.reth.rs
119+
or use --list-snapshots to see them from the CLI.
120+
118121
Available snapshot sources:
119-
- https://www.merkle.io/snapshots (default, mainnet archive)
122+
- https://snapshots.reth.rs (default)
120123
- https://publicnode.com/snapshots (full nodes & testnets)
121124
122125
If no URL is provided, the latest archive snapshot for the selected chain
123-
will be proposed for download from https://downloads.merkle.io.
126+
will be proposed for download from https://snapshots-r2.reth.rs.
124127
125128
Local file:// URLs are also supported for extracting snapshots from disk.
126129
@@ -168,6 +171,11 @@ Storage:
168171
169172
[default: 8]
170173
174+
--list
175+
List available snapshots from snapshots.reth.rs and exit.
176+
177+
Queries the snapshots API and prints all available snapshots for the selected chain, including block number, size, and manifest URL.
178+
171179
Logging:
172180
--log.stdout.format <FORMAT>
173181
The format to use for logs written to stdout

0 commit comments

Comments
 (0)