/ cli / src / commands / developer / scan.rs
scan.rs
  1  // Copyright (c) 2025 ADnet Contributors
  2  // This file is part of the AlphaOS library.
  3  
  4  // Licensed under the Apache License, Version 2.0 (the "License");
  5  // you may not use this file except in compliance with the License.
  6  // You may obtain a copy of the License at:
  7  
  8  // http://www.apache.org/licenses/LICENSE-2.0
  9  
 10  // Unless required by applicable law or agreed to in writing, software
 11  // distributed under the License is distributed on an "AS IS" BASIS,
 12  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 13  // See the License for the specific language governing permissions and
 14  // limitations under the License.
 15  
 16  use super::DEFAULT_ENDPOINT;
 17  use crate::{
 18      commands::Developer,
 19      helpers::{args::prepare_endpoint, dev::get_development_key},
 20  };
 21  
 22  use alphaos_node_cdn::CDN_BASE_URL;
 23  use alphaos_utilities::SimpleStoppable;
 24  
 25  use alphavm::{
 26      console::network::Network,
 27      prelude::{Ciphertext, Field, Plaintext, PrivateKey, Record, ViewKey, block::Block},
 28  };
 29  
 30  #[cfg(not(feature = "test_targets"))]
 31  use alphavm::prelude::FromBytes;
 32  
 33  use anyhow::{Context, Result, anyhow, bail, ensure};
 34  use clap::{Parser, builder::NonEmptyStringValueParser};
 35  #[cfg(feature = "locktick")]
 36  use locktick::parking_lot::RwLock;
 37  #[cfg(not(feature = "locktick"))]
 38  use parking_lot::RwLock;
 39  use std::{
 40      io::{Write, stdout},
 41      str::FromStr,
 42      sync::Arc,
 43  };
 44  use tracing::debug;
 45  use ureq::http::Uri;
 46  use zeroize::Zeroize;
 47  
 48  const MAX_BLOCK_RANGE: u32 = 50;
 49  
 50  /// Scan the AlphaOS node for records.
 51  #[derive(Debug, Parser)]
 52  #[clap(
 53      // The user needs to set view_key, private_key, or dev_key,
 54      // but they cannot set private_key and dev_key.
 55      group(clap::ArgGroup::new("key").required(true).multiple(false))
 56  )]
 57  pub struct Scan {
 58      /// The private key used for unspent records.
 59      #[clap(short, long, group = "key", value_parser=NonEmptyStringValueParser::default())]
 60      private_key: Option<String>,
 61  
 62      /// The view key used to scan for records.
 63      /// (if a private key is given, the view key is automatically derived and should not be set)
 64      #[clap(short, long, group = "key", value_parser=NonEmptyStringValueParser::default())]
 65      view_key: Option<String>,
 66  
 67      /// Use a development private key to scan for records.
 68      #[clap(long, group = "key")]
 69      dev_key: Option<u16>,
 70  
 71      /// The block height to start scanning from.
 72      /// Will scan until the most recent block or the height specified with `--end`.
 73      #[clap(long, conflicts_with = "last")]
 74      start: Option<u32>,
 75  
 76      /// The block height to stop scanning at (exclusive).
 77      /// Will start scanning at the geneiss block or the height specified with `--start`.
 78      #[clap(long, conflicts_with = "last")]
 79      end: Option<u32>,
 80  
 81      /// Scan the latest `n` blocks.
 82      #[clap(long)]
 83      last: Option<u32>,
 84  
 85      /// The endpoint to scan blocks from.
 86      #[clap(long, default_value = DEFAULT_ENDPOINT)]
 87      endpoint: Uri,
 88  
 89      /// Sets verbosity of log output. By default, no logs are shown.
 90      #[clap(long)]
 91      verbosity: Option<u8>,
 92  }
 93  
 94  impl Drop for Scan {
 95      /// Zeroize the private key when the `Execute` struct goes out of scope.
 96      fn drop(&mut self) {
 97          if let Some(mut pk) = self.private_key.take() {
 98              pk.zeroize()
 99          }
100      }
101  }
102  
103  impl Scan {
104      /// Scan the network for records.
105      pub fn parse<N: Network>(self) -> Result<String> {
106          let endpoint = prepare_endpoint(self.endpoint.clone())?;
107  
108          // Derive the view key and optional private key.
109          let (private_key, view_key) = self.parse_account::<N>()?;
110  
111          // Find the start and end height to scan.
112          let (start_height, end_height) = self.parse_block_range::<N>(&endpoint)?;
113  
114          // Fetch the records from the network.
115          let records = Self::fetch_records::<N>(private_key, &view_key, &endpoint, start_height, end_height)
116              .with_context(|| "Failed to fetch records")?;
117  
118          // Output the decrypted records associated with the view key.
119          if records.is_empty() {
120              Ok("No records found".to_string())
121          } else {
122              if private_key.is_none() {
123                  println!("⚠️  This list may contain records that have already been spent.\n");
124              }
125  
126              Ok(serde_json::to_string_pretty(&records)?.replace("\\n", ""))
127          }
128      }
129  
130      /// Returns the view key and optional private key, from the given configurations.
131      fn parse_account<N: Network>(&self) -> Result<(Option<PrivateKey<N>>, ViewKey<N>)> {
132          if let Some(private_key) = &self.private_key {
133              let private_key = PrivateKey::<N>::from_str(private_key)?;
134              let view_key = ViewKey::<N>::try_from(private_key)?;
135              Ok((Some(private_key), view_key))
136          } else if let Some(index) = &self.dev_key {
137              let private_key = get_development_key(*index)?;
138              let view_key = ViewKey::<N>::try_from(private_key)?;
139              Ok((Some(private_key), view_key))
140          } else if let Some(view_key) = &self.view_key {
141              Ok((None, ViewKey::<N>::from_str(view_key)?))
142          } else {
143              // This will be caught by clap
144              unreachable!();
145          }
146      }
147  
148      /// Returns the `start` and `end` blocks to scan.
149      fn parse_block_range<N: Network>(&self, endpoint: &Uri) -> Result<(u32, u32)> {
150          // Compute the end height.
151          let end = if let Some(end) = self.end {
152              end
153          } else {
154              // If not end height was given, request the latest block height from the endpoint.
155              let (endpoint, _api_version) = Developer::build_endpoint::<N>(endpoint, "block/height/latest")?;
156              let result = ureq::get(&endpoint).call().map_err(|e| e.into());
157              let end: u32 = Developer::handle_ureq_result(result)
158                  .and_then(|body| body.ok_or(anyhow!("Endpoint returned 404 for latest block height")))?
159                  .read_to_string()?
160                  .parse()?;
161  
162              debug!("Set end height to {end} based on latest block height of the endpoint");
163              end
164          };
165  
166          // Compute the start height.
167          let start = if let Some(start) = self.start {
168              start
169          } else if let Some(last) = self.last {
170              let start = end.saturating_sub(last);
171              debug!("Setting start height to {start} (based on last={last} and end={end})");
172              start
173          } else {
174              debug!("Picking default value (0) for start height");
175              0
176          };
177  
178          ensure!(end > start, "The given scan range is invalid (start = {start}, end = {end})");
179  
180          // Print a warning message if the user is attempting to scan the whole chain.
181          if start == 0 && self.end.is_none() {
182              println!("⚠️  Attention - Scanning the entire chain. This may take a while...\n");
183          }
184  
185          Ok((start, end))
186      }
187  
188      /// Returns the CDN to prefetch initial blocks from, from the given configurations.
189      fn parse_cdn<N: Network>() -> Result<Uri> {
190          // This should always succeed as the base URL is hardcoded.
191          Uri::try_from(format!("{CDN_BASE_URL}/{}", N::SHORT_NAME)).with_context(|| "Unexpected error")
192      }
193  
194      /// Fetch owned ciphertext records from the endpoint.
195      fn fetch_records<N: Network>(
196          private_key: Option<PrivateKey<N>>,
197          view_key: &ViewKey<N>,
198          endpoint: &Uri,
199          start_height: u32,
200          end_height: u32,
201      ) -> Result<Vec<Record<N, Plaintext<N>>>> {
202          // Check the bounds of the request.
203          if start_height > end_height {
204              bail!("Invalid block range. Start height ({start_height}) is not smaller than end height ({end_height}).");
205          }
206  
207          // Derive the x-coordinate of the address corresponding to the given view key.
208          let address_x_coordinate = view_key.to_address().to_x_coordinate();
209  
210          // Initialize a vector to store the records.
211          let records = Arc::new(RwLock::new(Vec::new()));
212  
213          // Calculate the number of blocks to scan.
214          let total_blocks = end_height.saturating_sub(start_height);
215  
216          // Log the initial progress.
217          print!("\rScanning {total_blocks} blocks for records (0% complete)...");
218          stdout().flush()?;
219  
220          // If the CLI was compiled with test targets, always assume the endpoint is on a development network.
221          #[cfg(feature = "test_targets")]
222          let is_development_network = true;
223  
224          // Otherwise, determine if the endpoint is on a development network based on its genesis block.
225          #[cfg(not(feature = "test_targets"))]
226          let is_development_network = {
227              // Fetch the genesis block from the endpoint.
228              let endpoint_genesis_block: Block<N> = match Developer::http_get_json::<N, _>(endpoint, "block/0")? {
229                  Some(block) => block,
230                  None => bail!("Enpoint returend 404 for genesis block"),
231              };
232  
233              // If the endpoint's block differs from our (production) block, it is on a development network.
234              endpoint_genesis_block != Block::from_bytes_le(N::genesis_bytes())?
235          };
236  
237          // Determine the request start height.
238          let mut request_start = match is_development_network {
239              true => start_height,
240              false => {
241                  // Parse the CDN endpoint.
242                  let cdn_endpoint = Self::parse_cdn::<N>()?;
243                  // Scan the CDN first for records.
244                  let new_start_height = Self::scan_from_cdn(
245                      start_height,
246                      end_height,
247                      &cdn_endpoint,
248                      endpoint,
249                      private_key,
250                      *view_key,
251                      address_x_coordinate,
252                      records.clone(),
253                  )?;
254  
255                  // Scan the remaining blocks from the endpoint.
256                  new_start_height.max(start_height)
257              }
258          };
259  
260          // Scan the endpoint for the remaining blocks.
261          while request_start <= end_height {
262              // Log the progress.
263              let percentage_complete = request_start.saturating_sub(start_height) as f64 * 100.0 / total_blocks as f64;
264              print!("\rScanning {total_blocks} blocks for records ({percentage_complete:.2}% complete)...");
265              stdout().flush()?;
266  
267              let num_blocks_to_request =
268                  std::cmp::min(MAX_BLOCK_RANGE, end_height.saturating_sub(request_start).saturating_add(1));
269              let request_end = request_start.saturating_add(num_blocks_to_request);
270  
271              // Fetch blocks.
272              let blocks: Vec<Block<N>> =
273                  Developer::http_get_json::<N, _>(endpoint, &format!("blocks?start={request_start}&end={request_end}"))
274                      .and_then(|blocks| blocks.ok_or(anyhow!("Enpoint returend 404 for the specified block range")))
275                      .with_context(|| format!("Failed to fetch blocks range {request_start}..{request_end}"))?;
276  
277              // Scan the blocks for owned records.
278              for block in &blocks {
279                  Self::scan_block(block, endpoint, private_key, view_key, &address_x_coordinate, records.clone())
280                      .with_context(|| format!("Failed to parse block {}", block.hash()))?;
281              }
282  
283              request_start = request_start.saturating_add(num_blocks_to_request);
284          }
285  
286          // Print the final complete message.
287          println!("\rScanning {total_blocks} blocks for records (100% complete)...   \n");
288          stdout().flush()?;
289  
290          let result = records.read().clone();
291          Ok(result)
292      }
293  
294      /// Scan the blocks from the CDN. Returns the current height scanned to.
295      #[allow(clippy::too_many_arguments, clippy::type_complexity)]
296      fn scan_from_cdn<N: Network>(
297          start_height: u32,
298          end_height: u32,
299          cdn: &Uri,
300          endpoint: &Uri,
301          private_key: Option<PrivateKey<N>>,
302          view_key: ViewKey<N>,
303          address_x_coordinate: Field<N>,
304          records: Arc<RwLock<Vec<Record<N, Plaintext<N>>>>>,
305      ) -> Result<u32> {
306          // Calculate the number of blocks to scan.
307          let total_blocks = end_height.saturating_sub(start_height);
308  
309          // Get the start_height with
310          let cdn_request_start = start_height.saturating_sub(start_height % MAX_BLOCK_RANGE);
311          let cdn_request_end = end_height.saturating_sub(end_height % MAX_BLOCK_RANGE).saturating_add(MAX_BLOCK_RANGE);
312  
313          // Construct the runtime.
314          let rt = tokio::runtime::Runtime::new()?;
315  
316          // Create a placeholder shutdown flag.
317          let _shutdown = SimpleStoppable::new();
318  
319          // Copy endpoint for background task.
320          let endpoint = endpoint.clone();
321  
322          // Scan the blocks via the CDN.
323          rt.block_on(async move {
324              let result =
325                  alphaos_node_cdn::load_blocks(cdn, cdn_request_start, Some(cdn_request_end), _shutdown, move |block| {
326                      // Check if the block is within the requested range.
327                      if block.height() < start_height || block.height() > end_height {
328                          return Ok(());
329                      }
330  
331                      // Log the progress.
332                      let percentage_complete =
333                          block.height().saturating_sub(start_height) as f64 * 100.0 / total_blocks as f64;
334                      print!("\rScanning {total_blocks} blocks for records ({percentage_complete:.2}% complete)...");
335                      stdout().flush()?;
336  
337                      // Scan the block for records.
338                      Self::scan_block(
339                          &block,
340                          &endpoint,
341                          private_key,
342                          &view_key,
343                          &address_x_coordinate,
344                          records.clone(),
345                      )?;
346  
347                      Ok(())
348                  })
349                  .await;
350              match result {
351                  Ok(height) => Ok(height),
352                  Err(error) => {
353                      eprintln!("Error loading blocks from CDN - (height, error):{error:?}");
354                      Ok(error.0)
355                  }
356              }
357          })
358      }
359  
360      /// Scan a block for owned records.
361      #[allow(clippy::type_complexity)]
362      fn scan_block<N: Network>(
363          block: &Block<N>,
364          endpoint: &Uri,
365          private_key: Option<PrivateKey<N>>,
366          view_key: &ViewKey<N>,
367          address_x_coordinate: &Field<N>,
368          records: Arc<RwLock<Vec<Record<N, Plaintext<N>>>>>,
369      ) -> Result<()> {
370          for (commitment, ciphertext_record) in block.records() {
371              // Check if the record is owned by the given view key.
372              if ciphertext_record.is_owner_with_address_x_coordinate(view_key, address_x_coordinate) {
373                  // Decrypt and optionally filter the records.
374                  if let Some(record) =
375                      Self::decrypt_record(private_key, view_key, endpoint, *commitment, ciphertext_record)
376                          .with_context(|| "Failed to decrypt record")?
377                  {
378                      records.write().push(record);
379                  }
380              }
381          }
382  
383          Ok(())
384      }
385  
386      /// Decrypts the ciphertext record and filters spend record if a private key was provided.
387      fn decrypt_record<N: Network>(
388          private_key: Option<PrivateKey<N>>,
389          view_key: &ViewKey<N>,
390          endpoint: &Uri,
391          commitment: Field<N>,
392          ciphertext_record: &Record<N, Ciphertext<N>>,
393      ) -> Result<Option<Record<N, Plaintext<N>>>> {
394          // Check if a private key was provided.
395          if let Some(private_key) = private_key {
396              // Compute the serial number.
397              let serial_number = Record::<N, Plaintext<N>>::serial_number(private_key, commitment)?;
398  
399              // Establish the endpoint.
400              let (endpoint, _api_version) =
401                  Developer::build_endpoint::<N>(endpoint, &format!("find/transitionID/{serial_number}"))?;
402  
403              // Check if the record is spent.
404              match ureq::get(&endpoint).call() {
405                  // On success, skip as the record is spent.
406                  Ok(_) => Ok(None),
407                  // On error, add the record.
408                  Err(_error) => {
409                      // TODO: Dedup the error types. We're adding the record as valid because the endpoint failed,
410                      //  meaning it couldn't find the serial number (ie. unspent). However if there's a DNS error or request error,
411                      //  we have a false positive here then.
412                      // Decrypt the record.
413                      Ok(Some(ciphertext_record.decrypt(view_key)?))
414                  }
415              }
416          } else {
417              // If no private key was provided, return the record.
418              Ok(Some(ciphertext_record.decrypt(view_key)?))
419          }
420      }
421  }
422  
423  #[cfg(test)]
424  mod tests {
425      use super::*;
426      use alphavm::prelude::{MainnetV0, TestRng};
427  
428      type CurrentNetwork = MainnetV0;
429  
430      #[test]
431      fn test_parse_account() {
432          let rng = &mut TestRng::default();
433  
434          // Generate private key and view key.
435          let private_key = PrivateKey::<CurrentNetwork>::new(rng).unwrap();
436          let view_key = ViewKey::try_from(private_key).unwrap();
437  
438          // Test passing the private key only
439          let config = Scan::try_parse_from(
440              ["snarkos", "--private-key", &format!("{private_key}"), "--last", "10", "--endpoint", "localhost"].iter(),
441          )
442          .unwrap();
443          assert!(config.parse_account::<CurrentNetwork>().is_ok());
444  
445          let (result_pkey, result_vkey) = config.parse_account::<CurrentNetwork>().unwrap();
446          assert_eq!(result_pkey, Some(private_key));
447          assert_eq!(result_vkey, view_key);
448  
449          // Passing an invalid view key should fail.
450          // Note: the current validation only rejects empty strings.
451          let err = Scan::try_parse_from(["snarkos", "--view-key", "", "--last", "10", "--endpoint", "localhost"].iter())
452              .unwrap_err();
453          assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
454  
455          // Test passing the view key only
456          let config = Scan::try_parse_from(
457              ["snarkos", "--view-key", &format!("{view_key}"), "--last", "10", "--endpoint", "localhost"].iter(),
458          )
459          .unwrap();
460  
461          let (result_pkey, result_vkey) = config.parse_account::<CurrentNetwork>().unwrap();
462          assert_eq!(result_pkey, None);
463          assert_eq!(result_vkey, view_key);
464  
465          // Passing both will generate an error.
466          let err = Scan::try_parse_from(
467              [
468                  "snarkos",
469                  "--private-key",
470                  &format!("{private_key}"),
471                  "--view-key",
472                  &format!("{view_key}"),
473                  "--last",
474                  "10",
475                  "--endpoint",
476                  "localhost",
477              ]
478              .iter(),
479          )
480          .unwrap_err();
481  
482          assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
483      }
484  
485      #[test]
486      fn test_parse_block_range() -> Result<()> {
487          // Hardcoded viewkey to ensure view key validation succeeds
488          const TEST_VIEW_KEY: &str = "AViewKey1qQVfici7WarfXgmq9iuH8tzRcrWtb8qYq1pEyRRE4kS7";
489  
490          let config = Scan::try_parse_from(
491              ["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "0", "--end", "10", "--endpoint", "localhost"].iter(),
492          )?;
493  
494          let endpoint = Uri::default();
495          config.parse_block_range::<CurrentNetwork>(&endpoint).with_context(|| "Failed to parse block range")?;
496  
497          // `start` height can't be greater than `end` height.
498          let config = Scan::try_parse_from(
499              ["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "10", "--end", "5", "--endpoint", "localhost"].iter(),
500          )?;
501  
502          let endpoint = Uri::default();
503          assert!(config.parse_block_range::<CurrentNetwork>(&endpoint).is_err());
504  
505          // `last` conflicts with `start`
506          let err = Scan::try_parse_from(
507              ["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "0", "--last", "10", "--endpoint=localhost"].iter(),
508          )
509          .unwrap_err();
510  
511          assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
512  
513          // `last` conflicts with `end`
514          let err = Scan::try_parse_from(
515              ["snarkos", "--view-key", TEST_VIEW_KEY, "--end", "10", "--last", "10", "--endpoint", "localhost"].iter(),
516          )
517          .unwrap_err();
518  
519          assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
520  
521          // `last` conflicts with `start` and `end`
522          let err = Scan::try_parse_from(
523              [
524                  "snarkos",
525                  "--view-key",
526                  TEST_VIEW_KEY,
527                  "--start",
528                  "0",
529                  "--end",
530                  "01",
531                  "--last",
532                  "10",
533                  "--endpoint",
534                  "localhost",
535              ]
536              .iter(),
537          )
538          .unwrap_err();
539  
540          assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
541  
542          Ok(())
543      }
544  }