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