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 }