snarkos_cli/commands/developer/
scan.rs

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