/ node / cdn / src / blocks.rs
blocks.rs
  1  // Copyright (c) 2025-2026 ACDC Network
  2  // This file is part of the alphaos library.
  3  //
  4  // Alpha Chain | Delta Chain Protocol
  5  // International Monetary Graphite.
  6  //
  7  // Derived from Aleo (https://aleo.org) and ProvableHQ (https://provable.com).
  8  // They built world-class ZK infrastructure. We installed the EASY button.
  9  // Their cryptography: elegant. Our modifications: bureaucracy-compatible.
 10  // Original brilliance: theirs. Robert's Rules: ours. Bugs: definitely ours.
 11  //
 12  // Original Aleo/ProvableHQ code subject to Apache 2.0 https://www.apache.org/licenses/LICENSE-2.0
 13  // All modifications and new work: CC0 1.0 Universal Public Domain Dedication.
 14  // No rights reserved. No permission required. No warranty. No refunds.
 15  //
 16  // https://creativecommons.org/publicdomain/zero/1.0/
 17  // SPDX-License-Identifier: CC0-1.0
 18  
 19  // Avoid a false positive from clippy:
 20  // https://github.com/rust-lang/rust-clippy/issues/6446
 21  #![allow(clippy::await_holding_lock)]
 22  
 23  use alphaos_utilities::{SignalHandler, Stoppable};
 24  
 25  use alphavm::prelude::{
 26      block::Block,
 27      store::ConsensusStorage,
 28      Deserialize,
 29      DeserializeOwned,
 30      Ledger,
 31      Network,
 32      Serialize,
 33  };
 34  
 35  use anyhow::{anyhow, bail, Result};
 36  use colored::Colorize;
 37  #[cfg(feature = "locktick")]
 38  use locktick::{parking_lot::Mutex, tokio::Mutex as TMutex};
 39  #[cfg(not(feature = "locktick"))]
 40  use parking_lot::Mutex;
 41  use reqwest::Client;
 42  use std::{
 43      cmp,
 44      sync::{
 45          atomic::{AtomicBool, AtomicU32, Ordering},
 46          Arc,
 47      },
 48      time::{Duration, Instant},
 49  };
 50  #[cfg(not(feature = "locktick"))]
 51  use tokio::sync::Mutex as TMutex;
 52  use tokio::task::JoinHandle;
 53  
 54  /// The number of blocks per file.
 55  const BLOCKS_PER_FILE: u32 = 50;
 56  /// The desired number of concurrent requests to the CDN.
 57  const CONCURRENT_REQUESTS: u32 = 16;
 58  /// Maximum number of pending sync blocks.
 59  const MAXIMUM_PENDING_BLOCKS: u32 = BLOCKS_PER_FILE * CONCURRENT_REQUESTS * 2;
 60  /// Maximum number of attempts for a request to the CDN.
 61  const MAXIMUM_REQUEST_ATTEMPTS: u8 = 10;
 62  
 63  /// The CDN base url.
 64  pub const CDN_BASE_URL: &str = "https://cdn.provable.com/v0/blocks";
 65  
 66  /// Updates the metrics during CDN sync.
 67  #[cfg(feature = "metrics")]
 68  fn update_block_metrics(height: u32) {
 69      // Update the BFT height metric.
 70      crate::metrics::gauge(crate::metrics::bft::HEIGHT, height as f64);
 71  }
 72  
 73  pub type SyncResult = Result<u32, (u32, anyhow::Error)>;
 74  
 75  /// Manages the CDN sync task.
 76  ///
 77  /// This is used, for example, in alphaos_node_rest to query how
 78  /// far along the CDN sync is.
 79  pub struct CdnBlockSync {
 80      base_url: http::Uri,
 81      /// The background tasks that performs the sync operation.
 82      task: Mutex<Option<JoinHandle<SyncResult>>>,
 83      /// This flag will be set to true once the sync task has been successfully awaited.
 84      done: AtomicBool,
 85  }
 86  
 87  impl CdnBlockSync {
 88      /// Spawn a background task that loads blocks from a CDN into the ledger.
 89      ///
 90      /// On success, this function returns the completed block height.
 91      /// On failure, this function returns the last successful block height (if any), along with the error.
 92      pub fn new<N: Network, C: ConsensusStorage<N>>(
 93          base_url: http::Uri,
 94          ledger: Ledger<N, C>,
 95          stoppable: Arc<SignalHandler>,
 96      ) -> Self {
 97          let task = {
 98              let base_url = base_url.clone();
 99              tokio::spawn(async move { Self::worker(base_url, ledger, stoppable).await })
100          };
101  
102          debug!("Started sync from CDN at {base_url}");
103          Self { done: AtomicBool::new(false), base_url, task: Mutex::new(Some(task)) }
104      }
105  
106      /// Did the CDN sync finish?
107      ///
108      /// Note: This can only return true if you call wait()
109      pub fn is_done(&self) -> bool {
110          self.done.load(Ordering::SeqCst)
111      }
112  
113      /// Wait for CDN sync to finish. Can only be called once.
114      pub async fn wait(&self) -> Result<SyncResult> {
115          let Some(hdl) = self.task.lock().take() else {
116              bail!("CDN task was already awaited");
117          };
118  
119          let result = hdl.await.map_err(|err| anyhow!("Failed to wait for CDN task: {err}"));
120          self.done.store(true, Ordering::SeqCst);
121          result
122      }
123  
124      async fn worker<N: Network, C: ConsensusStorage<N>>(
125          base_url: http::Uri,
126          ledger: Ledger<N, C>,
127          stoppable: Arc<dyn Stoppable>,
128      ) -> SyncResult {
129          // Fetch the node height.
130          let start_height = ledger.latest_height() + 1;
131          // Load the blocks from the CDN into the ledger.
132          let ledger_clone = ledger.clone();
133          let result = load_blocks(&base_url, start_height, None, stoppable, move |block: Block<N>| {
134              ledger_clone.advance_to_next_block(&block)
135          })
136          .await;
137  
138          // TODO (howardwu): Find a way to resolve integrity failures.
139          // If the sync failed, check the integrity of the ledger.
140          if let Err((completed_height, error)) = &result {
141              warn!("{error}");
142  
143              // If the sync made any progress, then check the integrity of the ledger.
144              if *completed_height != start_height {
145                  debug!("Synced the ledger up to block {completed_height}");
146  
147                  // Retrieve the latest height, according to the ledger.
148                  let node_height = *ledger.vm().block_store().heights().max().unwrap_or_default();
149                  // Check the integrity of the latest height.
150                  if &node_height != completed_height {
151                      return Err((*completed_height, anyhow!("The ledger height does not match the last sync height")));
152                  }
153  
154                  // Fetch the latest block from the ledger.
155                  if let Err(err) = ledger.get_block(node_height) {
156                      return Err((*completed_height, err));
157                  }
158              }
159  
160              Ok(*completed_height)
161          } else {
162              result
163          }
164      }
165  
166      pub async fn get_cdn_height(&self) -> anyhow::Result<u32> {
167          let client = Client::builder().use_rustls_tls().build()?;
168          cdn_height::<BLOCKS_PER_FILE>(&client, &self.base_url).await
169      }
170  }
171  
172  /// Loads blocks from a CDN and process them with the given function.
173  ///
174  /// On success, this function returns the completed block height.
175  /// On failure, this function returns the last successful block height (if any), along with the error.
176  pub async fn load_blocks<N: Network>(
177      base_url: &http::Uri,
178      start_height: u32,
179      end_height: Option<u32>,
180      stoppable: Arc<dyn Stoppable>,
181      process: impl FnMut(Block<N>) -> Result<()> + Clone + Send + Sync + 'static,
182  ) -> Result<u32, (u32, anyhow::Error)> {
183      // Create a Client to maintain a connection pool throughout the sync.
184      let client = match Client::builder().use_rustls_tls().build() {
185          Ok(client) => client,
186          Err(error) => {
187              return Err((start_height.saturating_sub(1), anyhow!("Failed to create a CDN request client - {error}")));
188          }
189      };
190  
191      // Fetch the CDN height.
192      let cdn_height = match cdn_height::<BLOCKS_PER_FILE>(&client, base_url).await {
193          Ok(cdn_height) => cdn_height,
194          Err(error) => return Err((start_height, error)),
195      };
196      // If the CDN height is less than the start height, return.
197      if cdn_height < start_height {
198          return Err((
199              start_height,
200              anyhow!("The given start height ({start_height}) must be less than the CDN height ({cdn_height})"),
201          ));
202      }
203  
204      // If the end height is not specified, set it to the CDN height.
205      // If the end height is greater than the CDN height, set the end height to the CDN height.
206      let end_height = cmp::min(end_height.unwrap_or(cdn_height), cdn_height);
207      // If the end height is less than the start height, return.
208      if end_height < start_height {
209          return Err((
210              start_height,
211              anyhow!("The given end height ({end_height}) must not be less than the start height ({start_height})"),
212          ));
213      }
214  
215      // Compute the CDN start height rounded down to the nearest multiple.
216      let cdn_start = start_height - (start_height % BLOCKS_PER_FILE);
217      // Set the CDN end height to the given end height.
218      let cdn_end = end_height;
219      // If the CDN range is empty, return.
220      if cdn_start >= cdn_end {
221          return Ok(cdn_end);
222      }
223  
224      // A collection of downloaded blocks pending insertion into the ledger.
225      let pending_blocks: Arc<TMutex<Vec<Block<N>>>> = Default::default();
226  
227      // Start a timer.
228      let timer = Instant::now();
229  
230      // Spawn a background task responsible for concurrent downloads.
231      let pending_blocks_clone = pending_blocks.clone();
232      let base_url = base_url.to_owned();
233  
234      {
235          let stoppable = stoppable.clone();
236          tokio::spawn(async move {
237              download_block_bundles(client, &base_url, cdn_start, cdn_end, pending_blocks_clone, stoppable).await;
238          });
239      }
240  
241      // A loop for inserting the pending blocks into the ledger.
242      let mut current_height = start_height.saturating_sub(1);
243      while current_height < end_height - 1 {
244          // If we are instructed to shut down, abort.
245          if stoppable.is_stopped() {
246              info!("Stopping block sync at {} - shutting down", current_height);
247              // We can shut down cleanly from here, as the node hasn't been started yet.
248              std::process::exit(0);
249          }
250  
251          let mut candidate_blocks = pending_blocks.lock().await;
252  
253          // Obtain the height of the nearest pending block.
254          let Some(next_height) = candidate_blocks.first().map(|b| b.height()) else {
255              debug!("No pending blocks yet");
256              drop(candidate_blocks);
257              tokio::time::sleep(Duration::from_secs(3)).await;
258              continue;
259          };
260  
261          // Wait if the nearest pending block is not the next one that can be inserted.
262          if next_height > current_height + 1 {
263              // There is a gap in pending blocks, we need to wait.
264              debug!("Waiting for the first relevant blocks ({} pending)", candidate_blocks.len());
265              drop(candidate_blocks);
266              tokio::time::sleep(Duration::from_secs(1)).await;
267              continue;
268          }
269  
270          // Obtain the first BLOCKS_PER_FILE applicable blocks.
271          let retained_blocks = candidate_blocks.split_off(BLOCKS_PER_FILE as usize);
272          let next_blocks = std::mem::replace(&mut *candidate_blocks, retained_blocks);
273          drop(candidate_blocks);
274  
275          // Initialize a temporary threadpool that can use the full CPU.
276          let threadpool = rayon::ThreadPoolBuilder::new().build().unwrap();
277  
278          // Attempt to advance the ledger using the CDN block bundle.
279          let mut process_clone = process.clone();
280          let stoppable_clone = stoppable.clone();
281          current_height = tokio::task::spawn_blocking(move || {
282              threadpool.install(|| {
283                  for block in next_blocks.into_iter().filter(|b| (start_height..end_height).contains(&b.height())) {
284                      // If we are instructed to shut down, abort.
285                      if stoppable_clone.is_stopped() {
286                          info!("Stopping block sync at {} - the node is shutting down", current_height);
287                          // We can shut down cleanly from here, as the node hasn't been started yet.
288                          std::process::exit(0);
289                      }
290  
291                      // Register the next block's height, as the block gets consumed next.
292                      let block_height = block.height();
293  
294                      // Insert the block into the ledger.
295                      process_clone(block)?;
296  
297                      // Update the current height.
298                      current_height = block_height;
299  
300                      // Update metrics.
301                      #[cfg(feature = "metrics")]
302                      update_block_metrics(current_height);
303  
304                      // Log the progress.
305                      log_progress::<BLOCKS_PER_FILE>(timer, current_height, cdn_start, cdn_end, "block");
306                  }
307  
308                  Ok(current_height)
309              })
310          })
311          .await
312          .map_err(|e| (current_height, e.into()))?
313          .map_err(|e| (current_height, e))?;
314      }
315  
316      Ok(current_height)
317  }
318  
319  async fn download_block_bundles<N: Network>(
320      client: Client,
321      base_url: &http::Uri,
322      cdn_start: u32,
323      cdn_end: u32,
324      pending_blocks: Arc<TMutex<Vec<Block<N>>>>,
325      stoppable: Arc<dyn Stoppable>,
326  ) {
327      // Keep track of the number of concurrent requests.
328      let active_requests: Arc<AtomicU32> = Default::default();
329  
330      let mut start = cdn_start;
331      while start < cdn_end - 1 {
332          // If we are instructed to shut down, stop downloading.
333          if stoppable.is_stopped() {
334              break;
335          }
336  
337          // Avoid collecting too many blocks in order to restrict memory use.
338          let num_pending_blocks = pending_blocks.lock().await.len();
339          if num_pending_blocks >= MAXIMUM_PENDING_BLOCKS as usize {
340              debug!("Maximum number of pending blocks reached ({num_pending_blocks}), waiting...");
341              tokio::time::sleep(Duration::from_secs(5)).await;
342              continue;
343          }
344  
345          // The number of concurrent requests is maintained at CONCURRENT_REQUESTS, unless the maximum
346          // number of pending blocks may be breached.
347          let active_request_count = active_requests.load(Ordering::Relaxed);
348          let num_requests =
349              cmp::min(CONCURRENT_REQUESTS, (MAXIMUM_PENDING_BLOCKS - num_pending_blocks as u32) / BLOCKS_PER_FILE)
350                  .saturating_sub(active_request_count);
351  
352          // Spawn concurrent requests for bundles of blocks.
353          for i in 0..num_requests {
354              let start = start + i * BLOCKS_PER_FILE;
355              let end = start + BLOCKS_PER_FILE;
356  
357              // If this request would breach the upper limit, stop downloading.
358              if end > cdn_end + BLOCKS_PER_FILE {
359                  debug!("Finishing network requests to the CDN...");
360                  break;
361              }
362  
363              let client_clone = client.clone();
364              let base_url_clone = base_url.clone();
365              let pending_blocks_clone = pending_blocks.clone();
366              let active_requests_clone = active_requests.clone();
367              let stoppable_clone = stoppable.clone();
368              tokio::spawn(async move {
369                  // Increment the number of active requests.
370                  active_requests_clone.fetch_add(1, Ordering::Relaxed);
371  
372                  let ctx = format!("blocks {start} to {end}");
373                  debug!("Requesting {ctx} (of {cdn_end})");
374  
375                  // Prepare the URL.
376                  let blocks_url = format!("{base_url_clone}/{start}.{end}.blocks");
377                  let ctx = format!("blocks {start} to {end}");
378                  // Download blocks, retrying on failure.
379                  let mut attempts = 0;
380                  let request_time = Instant::now();
381  
382                  loop {
383                      // Fetch the blocks.
384                      match cdn_get(client_clone.clone(), &blocks_url, &ctx).await {
385                          Ok::<Vec<Block<N>>, _>(blocks) => {
386                              // Keep the collection of pending blocks sorted by the height.
387                              let mut pending_blocks = pending_blocks_clone.lock().await;
388                              for block in blocks {
389                                  match pending_blocks.binary_search_by_key(&block.height(), |b| b.height()) {
390                                      Ok(_idx) => warn!("Found a duplicate pending block at height {}", block.height()),
391                                      Err(idx) => pending_blocks.insert(idx, block),
392                                  }
393                              }
394                              debug!("Received {ctx} {}", format!("(in {:.2?})", request_time.elapsed()).dimmed());
395                              break;
396                          }
397                          Err(error) => {
398                              // Increment the attempt counter, and wait with a linear backoff, or abort in
399                              // case the maximum number of attempts has been breached.
400                              attempts += 1;
401                              if attempts > MAXIMUM_REQUEST_ATTEMPTS {
402                                  warn!("Maximum number of requests to {blocks_url} reached - shutting down...");
403                                  stoppable_clone.stop();
404                                  break;
405                              }
406                              tokio::time::sleep(Duration::from_secs(attempts as u64 * 10)).await;
407                              warn!("{error} - retrying ({attempts} attempt(s) so far)");
408                          }
409                      }
410                  }
411  
412                  // Decrement the number of active requests.
413                  active_requests_clone.fetch_sub(1, Ordering::Relaxed);
414              });
415          }
416  
417          // Increase the starting block height for the subsequent requests.
418          start += BLOCKS_PER_FILE * num_requests;
419  
420          // A short sleep in order to allow some block processing to happen in the meantime.
421          tokio::time::sleep(Duration::from_secs(1)).await;
422      }
423  
424      debug!("Finished network requests to the CDN");
425  }
426  
427  /// Retrieves the CDN height with the given base URL.
428  ///
429  /// Note: This function decrements the tip by a few blocks, to ensure the
430  /// tip is not on a block that is not yet available on the CDN.
431  async fn cdn_height<const BLOCKS_PER_FILE: u32>(client: &Client, base_url: &http::Uri) -> Result<u32> {
432      // A representation of the 'latest.json' file object.
433      #[derive(Deserialize, Serialize, Debug)]
434      struct LatestState {
435          exclusive_height: u32,
436          inclusive_height: u32,
437          hash: String,
438      }
439      // Prepare the URL.
440      let latest_json_url = format!("{base_url}/latest.json");
441      // Send the request.
442      let response = match client.get(latest_json_url).send().await {
443          Ok(response) => response,
444          Err(error) => bail!("Failed to fetch the CDN height - {error}"),
445      };
446      // Parse the response.
447      let bytes = match response.bytes().await {
448          Ok(bytes) => bytes,
449          Err(error) => bail!("Failed to parse the CDN height response - {error}"),
450      };
451      // Parse the bytes for the string.
452      let latest_state_string = match bincode::deserialize::<String>(&bytes) {
453          Ok(string) => string,
454          Err(error) => bail!("Failed to deserialize the CDN height response - {error}"),
455      };
456      // Parse the string for the tip.
457      let tip = match serde_json::from_str::<LatestState>(&latest_state_string) {
458          Ok(latest) => latest.exclusive_height,
459          Err(error) => bail!("Failed to extract the CDN height response - {error}"),
460      };
461      // Decrement the tip by a few blocks to ensure the CDN is caught up.
462      let tip = tip.saturating_sub(10);
463      // Adjust the tip to the closest subsequent multiple of BLOCKS_PER_FILE.
464      Ok(tip - (tip % BLOCKS_PER_FILE) + BLOCKS_PER_FILE)
465  }
466  
467  /// Retrieves the objects from the CDN with the given URL.
468  async fn cdn_get<T: 'static + DeserializeOwned + Send>(client: Client, url: &str, ctx: &str) -> Result<T> {
469      // Fetch the bytes from the given URL.
470      let response = match client.get(url).send().await {
471          Ok(response) => response,
472          Err(error) => bail!("Failed to fetch {ctx} - {error}"),
473      };
474      // Parse the response.
475      let bytes = match response.bytes().await {
476          Ok(bytes) => bytes,
477          Err(error) => bail!("Failed to parse {ctx} - {error}"),
478      };
479  
480      // Parse the objects.
481      match tokio::task::spawn_blocking(move || bincode::deserialize::<T>(&bytes)).await {
482          Ok(Ok(objects)) => Ok(objects),
483          Ok(Err(error)) => bail!("Failed to deserialize {ctx} - {error}"),
484          Err(error) => bail!("Failed to join task for {ctx} - {error}"),
485      }
486  }
487  
488  /// Converts a duration into a string that humans can read easily.
489  ///
490  /// # Output
491  /// The output remains to be accurate but not too detailed (to reduce noise in the log).
492  /// It will give at most two levels of granularity, e.g., days and hours,
493  /// and only shows seconds if less than a minute remains.
494  fn to_human_readable_duration(duration: Duration) -> String {
495      // TODO: simplify this once the duration_constructors feature is stable
496      // See: https://github.com/rust-lang/rust/issues/140881
497      const SECS_PER_MIN: u64 = 60;
498      const MINS_PER_HOUR: u64 = 60;
499      const SECS_PER_HOUR: u64 = SECS_PER_MIN * MINS_PER_HOUR;
500      const HOURS_PER_DAY: u64 = 24;
501      const SECS_PER_DAY: u64 = SECS_PER_HOUR * HOURS_PER_DAY;
502  
503      let duration = duration.as_secs();
504  
505      if duration < 1 {
506          "less than one second".to_string()
507      } else if duration < SECS_PER_MIN {
508          format!("{duration} seconds")
509      } else if duration < SECS_PER_HOUR {
510          format!("{} minutes", duration / SECS_PER_MIN)
511      } else if duration < SECS_PER_DAY {
512          let mins = duration / SECS_PER_MIN;
513          format!("{hours} hours and {remainder} minutes", hours = mins / 60, remainder = mins % 60)
514      } else {
515          let days = duration / SECS_PER_DAY;
516          let hours = (duration % SECS_PER_DAY) / SECS_PER_HOUR;
517          format!("{days} days and {hours} hours")
518      }
519  }
520  
521  /// Logs the progress of the sync.
522  fn log_progress<const OBJECTS_PER_FILE: u32>(
523      timer: Instant,
524      current_index: u32,
525      cdn_start: u32,
526      mut cdn_end: u32,
527      object_name: &str,
528  ) {
529      debug_assert!(cdn_start <= cdn_end);
530      debug_assert!(current_index <= cdn_end);
531      debug_assert!(cdn_end >= 1);
532  
533      // Subtract 1, as the end of the range is exclusive.
534      cdn_end -= 1;
535  
536      // Compute the percentage completed of this particular sync.
537      let sync_percentage =
538          (current_index.saturating_sub(cdn_start) * 100).checked_div(cdn_end.saturating_sub(cdn_start)).unwrap_or(100);
539  
540      // Compute the number of files processed so far.
541      let num_files_done = 1 + (current_index - cdn_start) / OBJECTS_PER_FILE;
542      // Compute the number of files remaining.
543      let num_files_remaining = 1 + (cdn_end.saturating_sub(current_index)) / OBJECTS_PER_FILE;
544      // Compute the milliseconds per file.
545      let millis_per_file = timer.elapsed().as_millis() / num_files_done as u128;
546      // Compute the heuristic slowdown factor (in millis).
547      let slowdown = 100 * num_files_remaining as u128;
548      // Compute the time remaining (in millis).
549      let time_remaining = {
550          let remaining = num_files_remaining as u128 * millis_per_file + slowdown;
551          to_human_readable_duration(Duration::from_secs((remaining / 1000) as u64))
552      };
553      // Prepare the estimate message (in secs).
554      let estimate = format!("(started at height {cdn_start}, est. {time_remaining} remaining)");
555      // Log the progress.
556      info!(
557          "Reached {object_name} {current_index} of {cdn_end} - Sync is {sync_percentage}% complete {}",
558          estimate.dimmed()
559      );
560  }
561  
562  #[cfg(test)]
563  mod tests {
564      use super::{cdn_height, load_blocks, log_progress, BLOCKS_PER_FILE, CDN_BASE_URL};
565  
566      use alphaos_utilities::SimpleStoppable;
567  
568      use alphavm::prelude::{block::Block, MainnetV0};
569  
570      use http::Uri;
571      use parking_lot::RwLock;
572      use std::{sync::Arc, time::Instant};
573  
574      type CurrentNetwork = MainnetV0;
575  
576      fn check_load_blocks(start: u32, end: Option<u32>, expected: usize) {
577          let blocks = Arc::new(RwLock::new(Vec::new()));
578          let blocks_clone = blocks.clone();
579          let process = move |block: Block<CurrentNetwork>| {
580              blocks_clone.write().push(block);
581              Ok(())
582          };
583  
584          let testnet_cdn_url = Uri::try_from(format!("{CDN_BASE_URL}/mainnet")).unwrap();
585  
586          let rt = tokio::runtime::Runtime::new().unwrap();
587          rt.block_on(async {
588              let completed_height =
589                  load_blocks(&testnet_cdn_url, start, end, SimpleStoppable::new(), process).await.unwrap();
590              assert_eq!(blocks.read().len(), expected);
591              if expected > 0 {
592                  assert_eq!(blocks.read().last().unwrap().height(), completed_height);
593              }
594              // Check they are sequential.
595              for (i, block) in blocks.read().iter().enumerate() {
596                  assert_eq!(block.height(), start + i as u32);
597              }
598          });
599      }
600  
601      #[test]
602      fn test_load_blocks_0_to_50() {
603          let start_height = 0;
604          let end_height = Some(50);
605          check_load_blocks(start_height, end_height, 50);
606      }
607  
608      #[test]
609      fn test_load_blocks_50_to_100() {
610          let start_height = 50;
611          let end_height = Some(100);
612          check_load_blocks(start_height, end_height, 50);
613      }
614  
615      #[test]
616      fn test_load_blocks_0_to_123() {
617          let start_height = 0;
618          let end_height = Some(123);
619          check_load_blocks(start_height, end_height, 123);
620      }
621  
622      #[test]
623      fn test_load_blocks_46_to_234() {
624          let start_height = 46;
625          let end_height = Some(234);
626          check_load_blocks(start_height, end_height, 188);
627      }
628  
629      #[test]
630      fn test_cdn_height() {
631          let rt = tokio::runtime::Runtime::new().unwrap();
632          let client = reqwest::Client::builder().use_rustls_tls().build().unwrap();
633          let testnet_cdn_url = Uri::try_from(format!("{CDN_BASE_URL}/mainnet")).unwrap();
634          rt.block_on(async {
635              let height = cdn_height::<BLOCKS_PER_FILE>(&client, &testnet_cdn_url).await.unwrap();
636              assert!(height > 0);
637          });
638      }
639  
640      #[test]
641      fn test_log_progress() {
642          // This test sanity checks that basic arithmetic is correct (i.e. no divide by zero, etc.).
643          let timer = Instant::now();
644          let cdn_start = 0;
645          let cdn_end = 100;
646          let object_name = "blocks";
647          log_progress::<10>(timer, 0, cdn_start, cdn_end, object_name);
648          log_progress::<10>(timer, 10, cdn_start, cdn_end, object_name);
649          log_progress::<10>(timer, 20, cdn_start, cdn_end, object_name);
650          log_progress::<10>(timer, 30, cdn_start, cdn_end, object_name);
651          log_progress::<10>(timer, 40, cdn_start, cdn_end, object_name);
652          log_progress::<10>(timer, 50, cdn_start, cdn_end, object_name);
653          log_progress::<10>(timer, 60, cdn_start, cdn_end, object_name);
654          log_progress::<10>(timer, 70, cdn_start, cdn_end, object_name);
655          log_progress::<10>(timer, 80, cdn_start, cdn_end, object_name);
656          log_progress::<10>(timer, 90, cdn_start, cdn_end, object_name);
657          log_progress::<10>(timer, 100, cdn_start, cdn_end, object_name);
658      }
659  }