1use ahash::HashSet;
5use anyhow::{Context as _, bail};
6use clap::Args;
7use regex::Regex;
8use serde::Deserialize;
9use std::sync::LazyLock;
10use std::time::SystemTime;
11use url::Url;
12
13use crate::networks::{NetworkChain, calculate_expected_epoch};
14use crate::shim::clock::{ChainEpoch, EPOCH_DURATION_SECONDS};
15use crate::utils::net::global_http_client;
16
17const LIST_BASE: &str = "https://forest-archive.chainsafe.dev/list";
18
19const LITE_INTERVAL: ChainEpoch = 30_000;
20const DIFF_INTERVAL: ChainEpoch = 3_000;
21
22const MAINNET_GENESIS_TIMESTAMP: u64 = 1598306400; const CALIBNET_GENESIS_TIMESTAMP: u64 = 1667326380; #[derive(Debug, Args)]
27pub struct ArchiveMissingCommand {
28 #[arg(long, required = true)]
30 chain: NetworkChain,
31 #[arg(long)]
34 from: Option<ChainEpoch>,
35 #[arg(long)]
38 to: Option<ChainEpoch>,
39}
40
41#[derive(Debug, Deserialize)]
42struct ListingItem {
43 url: Url,
44}
45
46#[derive(Debug, Deserialize)]
47struct ListingResponse {
48 items: Vec<ListingItem>,
49}
50
51fn extract_height(url: &Url) -> Option<ChainEpoch> {
55 static RE: LazyLock<Regex> =
56 LazyLock::new(|| Regex::new(r"_height_(\d+)").expect("static regex is valid"));
57 let path = url.path();
58 let caps = RE.captures(path)?;
59 caps[1].parse().ok()
60}
61
62fn parse_listing_heights(data: &ListingResponse) -> HashSet<ChainEpoch> {
64 data.items
65 .iter()
66 .filter_map(|item| extract_height(&item.url))
67 .collect()
68}
69
70fn compute_required_lite(from: ChainEpoch, to: ChainEpoch) -> Vec<ChainEpoch> {
72 let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
73 let base_to = (to / LITE_INTERVAL) * LITE_INTERVAL;
74 (base_from..=base_to)
75 .step_by(LITE_INTERVAL as usize)
76 .collect()
77}
78
79fn compute_required_diff(from: ChainEpoch, to: ChainEpoch) -> Vec<ChainEpoch> {
81 let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
82 let base_to = (to / LITE_INTERVAL) * LITE_INTERVAL;
83 let diff_to = if to > base_to {
84 ((to - 1) / DIFF_INTERVAL) * DIFF_INTERVAL
85 } else if base_to >= DIFF_INTERVAL {
86 base_to - DIFF_INTERVAL
87 } else {
88 return Vec::new();
91 };
92 (base_from..=diff_to)
93 .step_by(DIFF_INTERVAL as usize)
94 .collect()
95}
96
97fn find_missing(required: &[ChainEpoch], available: &HashSet<ChainEpoch>) -> Vec<ChainEpoch> {
99 required
100 .iter()
101 .filter(|h| !available.contains(h))
102 .copied()
103 .collect()
104}
105
106async fn fetch_available_heights(
108 client: &reqwest::Client,
109 network: &str,
110 snapshot_type: &str,
111) -> anyhow::Result<HashSet<ChainEpoch>> {
112 let url = format!("{LIST_BASE}/{network}/{snapshot_type}?format=json");
113 let resp = client
114 .get(&url)
115 .send()
116 .await
117 .context("failed to fetch archive listing")?;
118 if !resp.status().is_success() {
119 bail!("{url}: HTTP {}", resp.status());
120 }
121 let data: ListingResponse = resp
122 .json()
123 .await
124 .context("failed to parse archive listing")?;
125 Ok(parse_listing_heights(&data))
126}
127
128impl ArchiveMissingCommand {
129 pub async fn run(self) -> anyhow::Result<()> {
130 let (network, genesis_ts) = match &self.chain {
131 NetworkChain::Mainnet => ("mainnet", MAINNET_GENESIS_TIMESTAMP),
132 NetworkChain::Calibnet => ("calibnet", CALIBNET_GENESIS_TIMESTAMP),
133 other => bail!("network {other} is not supported"),
134 };
135
136 let now = SystemTime::now()
137 .duration_since(SystemTime::UNIX_EPOCH)?
138 .as_secs();
139 let current_epoch =
140 calculate_expected_epoch(now, genesis_ts, EPOCH_DURATION_SECONDS as u32);
141
142 let from = self.from.unwrap_or(0);
143 let to = self.to.unwrap_or_else(|| current_epoch - DIFF_INTERVAL);
144
145 if from > to {
146 bail!("--from ({from}) must be <= --to ({to})");
147 }
148
149 println!(
150 "Checking {network} epochs {from}..={to} (current network epoch: {current_epoch})"
151 );
152
153 let client = global_http_client();
154
155 println!("Fetching archive listings...");
156 let (available_lite, available_diff) = tokio::try_join!(
157 fetch_available_heights(&client, network, "lite"),
158 fetch_available_heights(&client, network, "diff"),
159 )?;
160
161 println!(
162 "Archive has {} lite and {} diff snapshots.",
163 available_lite.len(),
164 available_diff.len()
165 );
166
167 let required_lite = compute_required_lite(from, to);
168 let required_diff = compute_required_diff(from, to);
169
170 let missing_lite = find_missing(&required_lite, &available_lite);
171 let missing_diff = find_missing(&required_diff, &available_diff);
172
173 let total_required = required_lite.len() + required_diff.len();
174 let total_missing = missing_lite.len() + missing_diff.len();
175
176 if total_missing == 0 {
177 let base_from = (from / LITE_INTERVAL) * LITE_INTERVAL;
178 println!(
179 "All {total_required} required snapshots are available (epochs {base_from}..={to}).",
180 );
181 } else {
182 println!("\n{total_missing} of {total_required} required snapshots are MISSING:\n");
183 if !missing_lite.is_empty() {
184 println!(" Missing lite snapshots:");
185 for h in &missing_lite {
186 println!(" lite at height {h}");
187 }
188 }
189 if !missing_diff.is_empty() {
190 println!(" Missing diff snapshots:");
191 for h in &missing_diff {
192 println!(" diff at height {h}");
193 }
194 }
195 bail!("{total_missing} of {total_required} required snapshots are missing");
196 }
197
198 Ok(())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_extract_height_lite() {
208 let url = Url::parse(
209 "https://example.com/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst",
210 )
211 .unwrap();
212 assert_eq!(extract_height(&url), Some(3510000));
213 }
214
215 #[test]
216 fn test_extract_height_diff() {
217 let url = Url::parse(
218 "https://example.com/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst",
219 )
220 .unwrap();
221 assert_eq!(extract_height(&url), Some(0));
222 let url = Url::parse(
223 "https://example.com/forest_diff_mainnet_2025-12-24_height_3480000+3000.forest.car.zst",
224 )
225 .unwrap();
226 assert_eq!(extract_height(&url), Some(3480000));
227 }
228
229 #[test]
230 fn test_extract_height_invalid() {
231 let url = Url::parse("https://example.com/not-a-snapshot").unwrap();
232 assert_eq!(extract_height(&url), None);
233 }
234
235 #[test]
236 fn test_compute_required_lite_single_segment() {
237 assert_eq!(compute_required_lite(30_000, 59_999), vec![30_000]);
239 }
240
241 #[test]
242 fn test_compute_required_lite_multiple_segments() {
243 assert_eq!(
244 compute_required_lite(30_000, 90_000),
245 vec![30_000, 60_000, 90_000]
246 );
247 }
248
249 #[test]
250 fn test_compute_required_lite_from_genesis() {
251 assert_eq!(compute_required_lite(0, 60_000), vec![0, 30_000, 60_000]);
252 }
253
254 #[test]
255 fn test_compute_required_lite_rounds_down() {
256 assert_eq!(compute_required_lite(5_000, 35_000), vec![0, 30_000]);
258 }
259
260 #[test]
261 fn test_compute_required_diff_within_segment() {
262 let diffs = compute_required_diff(30_000, 36_000);
264 assert_eq!(diffs, vec![30_000, 33_000]);
265 }
266
267 #[test]
268 fn test_compute_required_diff_exact_lite_boundary() {
269 let diffs = compute_required_diff(30_000, 60_000);
272 assert_eq!(
273 diffs,
274 vec![
275 30_000, 33_000, 36_000, 39_000, 42_000, 45_000, 48_000, 51_000, 54_000, 57_000
276 ]
277 );
278 }
279
280 #[test]
281 fn test_compute_required_diff_cross_segment() {
282 let diffs = compute_required_diff(57_000, 63_000);
284 assert_eq!(
286 diffs,
287 vec![
288 30_000, 33_000, 36_000, 39_000, 42_000, 45_000, 48_000, 51_000, 54_000, 57_000,
289 60_000
290 ]
291 );
292 }
293
294 #[test]
295 fn test_find_missing_none() {
296 let required = vec![0, 30_000, 60_000];
297 let available: HashSet<_> = HashSet::from_iter([0, 30_000, 60_000, 90_000]);
298 assert!(find_missing(&required, &available).is_empty());
299 }
300
301 #[test]
302 fn test_find_missing_some() {
303 let required = vec![0, 30_000, 60_000];
304 let available: HashSet<_> = HashSet::from_iter([0, 60_000]);
305 assert_eq!(find_missing(&required, &available), vec![30_000]);
306 }
307
308 #[test]
309 fn test_find_missing_all() {
310 let required = vec![0, 30_000];
311 let available: HashSet<ChainEpoch> = HashSet::default();
312 assert_eq!(find_missing(&required, &available), vec![0, 30_000]);
313 }
314
315 #[test]
316 fn test_parse_listing_heights_from_json() {
317 let json = r#"{
318 "total": 3,
319 "offset": 0,
320 "limit": 0,
321 "items": [
322 {
323 "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst",
324 "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-03-04_height_3510000.forest.car.zst.sha256sum",
325 "size": 7528742793,
326 "uploaded": "2026-03-05T00:52:34.198Z"
327 },
328 {
329 "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-02-22_height_3480000.forest.car.zst",
330 "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2026-02-22_height_3480000.forest.car.zst.sha256sum",
331 "size": 7440018317,
332 "uploaded": "2026-02-22T23:40:48.106Z"
333 },
334 {
335 "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2022-11-01_height_0.forest.car.zst",
336 "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/lite/forest_snapshot_calibnet_2022-11-01_height_0.forest.car.zst.sha256sum",
337 "size": 491234,
338 "uploaded": "2023-08-30T08:54:56.805Z"
339 }
340 ]
341 }"#;
342 let data: ListingResponse = serde_json::from_str(json).unwrap();
343 let heights = parse_listing_heights(&data);
344 assert_eq!(heights, HashSet::from_iter([0, 3_480_000, 3_510_000]));
345 }
346
347 #[test]
348 fn test_parse_listing_heights_with_diffs() {
349 let json = r#"{
350 "total": 2,
351 "offset": 0,
352 "limit": 0,
353 "items": [
354 {
355 "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2026-03-04_height_3510000+3000.forest.car.zst",
356 "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2026-03-04_height_3510000+3000.forest.car.zst.sha256sum",
357 "size": 123456,
358 "uploaded": "2026-03-05T01:00:00.000Z"
359 },
360 {
361 "url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst",
362 "sha256url": "https://forest-archive.chainsafe.dev/archive/forest/calibnet/diff/forest_diff_calibnet_2022-11-02_height_0+3000.forest.car.zst.sha256sum",
363 "size": 789012,
364 "uploaded": "2023-08-30T09:00:00.000Z"
365 }
366 ]
367 }"#;
368 let data: ListingResponse = serde_json::from_str(json).unwrap();
369 let heights = parse_listing_heights(&data);
370 assert_eq!(heights, HashSet::from_iter([0, 3_510_000]));
371 }
372
373 #[test]
374 fn test_end_to_end_missing_detection() {
375 let available_lite: HashSet<_> = HashSet::from_iter([0, 60_000]);
378 let available_diff: HashSet<_> = (0..60_000).step_by(DIFF_INTERVAL as usize).collect();
379
380 let required_lite = compute_required_lite(0, 60_000);
381 let required_diff = compute_required_diff(0, 60_000);
382
383 let missing_lite = find_missing(&required_lite, &available_lite);
384 let missing_diff = find_missing(&required_diff, &available_diff);
385
386 assert_eq!(missing_lite, vec![30_000]);
387 assert!(missing_diff.is_empty());
388 }
389
390 #[test]
391 fn test_end_to_end_all_present() {
392 let available_lite: HashSet<_> = HashSet::from_iter([0, 30_000, 60_000]);
393 let available_diff: HashSet<_> = (0..60_000).step_by(DIFF_INTERVAL as usize).collect();
394
395 let required_lite = compute_required_lite(0, 60_000);
396 let required_diff = compute_required_diff(0, 60_000);
397
398 assert!(find_missing(&required_lite, &available_lite).is_empty());
399 assert!(find_missing(&required_diff, &available_diff).is_empty());
400 }
401}