snarkos_cli/commands/developer/
scan.rs

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