snarkos_cli/commands/developer/
scan.rs

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