Skip to main content

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 snarkos_utilities::SimpleStoppable;
24
25use snarkvm::{
26    console::network::Network,
27    prelude::{Ciphertext, Field, Plaintext, PrivateKey, Record, ViewKey, block::Block},
28};
29
30#[cfg(not(feature = "test_targets"))]
31use snarkvm::prelude::FromBytes;
32
33use anyhow::{Context, Result, anyhow, bail, ensure};
34use clap::{Parser, builder::NonEmptyStringValueParser};
35#[cfg(feature = "locktick")]
36use locktick::parking_lot::RwLock;
37#[cfg(not(feature = "locktick"))]
38use parking_lot::RwLock;
39use std::{
40    io::{Write, stdout},
41    str::FromStr,
42    sync::Arc,
43};
44use tracing::debug;
45use ureq::http::Uri;
46use zeroize::Zeroize;
47
48const MAX_BLOCK_RANGE: u32 = 50;
49
50/// Scan the snarkOS 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)]
57pub 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
94impl 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
103impl 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                snarkos_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)]
424mod tests {
425    use super::*;
426    use snarkvm::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}