use super::DEFAULT_ENDPOINT;
use crate::{
commands::Developer,
helpers::{args::prepare_endpoint, dev::get_development_key},
};
use snarkos_node_cdn::CDN_BASE_URL;
use snarkos_utilities::SimpleStoppable;
use snarkvm::{
console::network::Network,
prelude::{Ciphertext, Field, Plaintext, PrivateKey, Record, ViewKey, block::Block},
};
#[cfg(not(feature = "test_targets"))]
use snarkvm::prelude::FromBytes;
use anyhow::{Context, Result, anyhow, bail, ensure};
use clap::{Parser, builder::NonEmptyStringValueParser};
#[cfg(feature = "locktick")]
use locktick::parking_lot::RwLock;
#[cfg(not(feature = "locktick"))]
use parking_lot::RwLock;
use std::{
io::{Write, stdout},
str::FromStr,
sync::Arc,
};
use tracing::debug;
use ureq::http::Uri;
use zeroize::Zeroize;
const MAX_BLOCK_RANGE: u32 = 50;
#[derive(Debug, Parser)]
#[clap(
// The user needs to set view_key, private_key, or dev_key,
// but they cannot set private_key and dev_key.
group(clap::ArgGroup::new("key").required(true).multiple(false))
)]
pub struct Scan {
#[clap(short, long, group = "key", value_parser=NonEmptyStringValueParser::default())]
private_key: Option<String>,
#[clap(short, long, group = "key", value_parser=NonEmptyStringValueParser::default())]
view_key: Option<String>,
#[clap(long, group = "key")]
dev_key: Option<u16>,
#[clap(long, conflicts_with = "last")]
start: Option<u32>,
#[clap(long, conflicts_with = "last")]
end: Option<u32>,
#[clap(long)]
last: Option<u32>,
#[clap(long, default_value = DEFAULT_ENDPOINT)]
endpoint: Uri,
#[clap(long)]
verbosity: Option<u8>,
}
impl Drop for Scan {
fn drop(&mut self) {
if let Some(mut pk) = self.private_key.take() {
pk.zeroize()
}
}
}
impl Scan {
pub fn parse<N: Network>(self) -> Result<String> {
let endpoint = prepare_endpoint(self.endpoint.clone())?;
let (private_key, view_key) = self.parse_account::<N>()?;
let (start_height, end_height) = self.parse_block_range::<N>(&endpoint)?;
let records = Self::fetch_records::<N>(private_key, &view_key, &endpoint, start_height, end_height)
.with_context(|| "Failed to fetch records")?;
if records.is_empty() {
Ok("No records found".to_string())
} else {
if private_key.is_none() {
println!("⚠️ This list may contain records that have already been spent.\n");
}
Ok(serde_json::to_string_pretty(&records)?.replace("\\n", ""))
}
}
fn parse_account<N: Network>(&self) -> Result<(Option<PrivateKey<N>>, ViewKey<N>)> {
if let Some(private_key) = &self.private_key {
let private_key = PrivateKey::<N>::from_str(private_key)?;
let view_key = ViewKey::<N>::try_from(private_key)?;
Ok((Some(private_key), view_key))
} else if let Some(index) = &self.dev_key {
let private_key = get_development_key(*index)?;
let view_key = ViewKey::<N>::try_from(private_key)?;
Ok((Some(private_key), view_key))
} else if let Some(view_key) = &self.view_key {
Ok((None, ViewKey::<N>::from_str(view_key)?))
} else {
unreachable!();
}
}
fn parse_block_range<N: Network>(&self, endpoint: &Uri) -> Result<(u32, u32)> {
let end = if let Some(end) = self.end {
end
} else {
let (endpoint, _api_version) = Developer::build_endpoint::<N>(endpoint, "block/height/latest")?;
let result = ureq::get(&endpoint).call().map_err(|e| e.into());
let end: u32 = Developer::handle_ureq_result(result)
.and_then(|body| body.ok_or(anyhow!("Endpoint returned 404 for latest block height")))?
.read_to_string()?
.parse()?;
debug!("Set end height to {end} based on latest block height of the endpoint");
end
};
let start = if let Some(start) = self.start {
start
} else if let Some(last) = self.last {
let start = end.saturating_sub(last);
debug!("Setting start height to {start} (based on last={last} and end={end})");
start
} else {
debug!("Picking default value (0) for start height");
0
};
ensure!(end > start, "The given scan range is invalid (start = {start}, end = {end})");
if start == 0 && self.end.is_none() {
println!("⚠️ Attention - Scanning the entire chain. This may take a while...\n");
}
Ok((start, end))
}
fn parse_cdn<N: Network>() -> Result<Uri> {
Uri::try_from(format!("{CDN_BASE_URL}/{}", N::SHORT_NAME)).with_context(|| "Unexpected error")
}
fn fetch_records<N: Network>(
private_key: Option<PrivateKey<N>>,
view_key: &ViewKey<N>,
endpoint: &Uri,
start_height: u32,
end_height: u32,
) -> Result<Vec<Record<N, Plaintext<N>>>> {
if start_height > end_height {
bail!("Invalid block range. Start height ({start_height}) is not smaller than end height ({end_height}).");
}
let address_x_coordinate = view_key.to_address().to_x_coordinate();
let records = Arc::new(RwLock::new(Vec::new()));
let total_blocks = end_height.saturating_sub(start_height);
print!("\rScanning {total_blocks} blocks for records (0% complete)...");
stdout().flush()?;
#[cfg(feature = "test_targets")]
let is_development_network = true;
#[cfg(not(feature = "test_targets"))]
let is_development_network = {
let endpoint_genesis_block: Block<N> = match Developer::http_get_json::<N, _>(endpoint, "block/0")? {
Some(block) => block,
None => bail!("Enpoint returend 404 for genesis block"),
};
endpoint_genesis_block != Block::from_bytes_le(N::genesis_bytes())?
};
let mut request_start = match is_development_network {
true => start_height,
false => {
let cdn_endpoint = Self::parse_cdn::<N>()?;
let new_start_height = Self::scan_from_cdn(
start_height,
end_height,
&cdn_endpoint,
endpoint,
private_key,
*view_key,
address_x_coordinate,
records.clone(),
)?;
new_start_height.max(start_height)
}
};
while request_start <= end_height {
let percentage_complete = request_start.saturating_sub(start_height) as f64 * 100.0 / total_blocks as f64;
print!("\rScanning {total_blocks} blocks for records ({percentage_complete:.2}% complete)...");
stdout().flush()?;
let num_blocks_to_request =
std::cmp::min(MAX_BLOCK_RANGE, end_height.saturating_sub(request_start).saturating_add(1));
let request_end = request_start.saturating_add(num_blocks_to_request);
let blocks: Vec<Block<N>> =
Developer::http_get_json::<N, _>(endpoint, &format!("blocks?start={request_start}&end={request_end}"))
.and_then(|blocks| blocks.ok_or(anyhow!("Enpoint returend 404 for the specified block range")))
.with_context(|| format!("Failed to fetch blocks range {request_start}..{request_end}"))?;
for block in &blocks {
Self::scan_block(block, endpoint, private_key, view_key, &address_x_coordinate, records.clone())
.with_context(|| format!("Failed to parse block {}", block.hash()))?;
}
request_start = request_start.saturating_add(num_blocks_to_request);
}
println!("\rScanning {total_blocks} blocks for records (100% complete)... \n");
stdout().flush()?;
let result = records.read().clone();
Ok(result)
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn scan_from_cdn<N: Network>(
start_height: u32,
end_height: u32,
cdn: &Uri,
endpoint: &Uri,
private_key: Option<PrivateKey<N>>,
view_key: ViewKey<N>,
address_x_coordinate: Field<N>,
records: Arc<RwLock<Vec<Record<N, Plaintext<N>>>>>,
) -> Result<u32> {
let total_blocks = end_height.saturating_sub(start_height);
let cdn_request_start = start_height.saturating_sub(start_height % MAX_BLOCK_RANGE);
let cdn_request_end = end_height.saturating_sub(end_height % MAX_BLOCK_RANGE).saturating_add(MAX_BLOCK_RANGE);
let rt = tokio::runtime::Runtime::new()?;
let _shutdown = SimpleStoppable::new();
let endpoint = endpoint.clone();
rt.block_on(async move {
let result =
snarkos_node_cdn::load_blocks(cdn, cdn_request_start, Some(cdn_request_end), _shutdown, move |block| {
if block.height() < start_height || block.height() > end_height {
return Ok(());
}
let percentage_complete =
block.height().saturating_sub(start_height) as f64 * 100.0 / total_blocks as f64;
print!("\rScanning {total_blocks} blocks for records ({percentage_complete:.2}% complete)...");
stdout().flush()?;
Self::scan_block(
&block,
&endpoint,
private_key,
&view_key,
&address_x_coordinate,
records.clone(),
)?;
Ok(())
})
.await;
match result {
Ok(height) => Ok(height),
Err(error) => {
eprintln!("Error loading blocks from CDN - (height, error):{error:?}");
Ok(error.0)
}
}
})
}
#[allow(clippy::type_complexity)]
fn scan_block<N: Network>(
block: &Block<N>,
endpoint: &Uri,
private_key: Option<PrivateKey<N>>,
view_key: &ViewKey<N>,
address_x_coordinate: &Field<N>,
records: Arc<RwLock<Vec<Record<N, Plaintext<N>>>>>,
) -> Result<()> {
for (commitment, ciphertext_record) in block.records() {
if ciphertext_record.is_owner_with_address_x_coordinate(view_key, address_x_coordinate) {
if let Some(record) =
Self::decrypt_record(private_key, view_key, endpoint, *commitment, ciphertext_record)
.with_context(|| "Failed to decrypt record")?
{
records.write().push(record);
}
}
}
Ok(())
}
fn decrypt_record<N: Network>(
private_key: Option<PrivateKey<N>>,
view_key: &ViewKey<N>,
endpoint: &Uri,
commitment: Field<N>,
ciphertext_record: &Record<N, Ciphertext<N>>,
) -> Result<Option<Record<N, Plaintext<N>>>> {
if let Some(private_key) = private_key {
let serial_number = Record::<N, Plaintext<N>>::serial_number(private_key, commitment)?;
let (endpoint, _api_version) =
Developer::build_endpoint::<N>(endpoint, &format!("find/transitionID/{serial_number}"))?;
match ureq::get(&endpoint).call() {
Ok(_) => Ok(None),
Err(_error) => {
Ok(Some(ciphertext_record.decrypt(view_key)?))
}
}
} else {
Ok(Some(ciphertext_record.decrypt(view_key)?))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use snarkvm::prelude::{MainnetV0, TestRng};
type CurrentNetwork = MainnetV0;
#[test]
fn test_parse_account() {
let rng = &mut TestRng::default();
let private_key = PrivateKey::<CurrentNetwork>::new(rng).unwrap();
let view_key = ViewKey::try_from(private_key).unwrap();
let config = Scan::try_parse_from(
["snarkos", "--private-key", &format!("{private_key}"), "--last", "10", "--endpoint", "localhost"].iter(),
)
.unwrap();
assert!(config.parse_account::<CurrentNetwork>().is_ok());
let (result_pkey, result_vkey) = config.parse_account::<CurrentNetwork>().unwrap();
assert_eq!(result_pkey, Some(private_key));
assert_eq!(result_vkey, view_key);
let err = Scan::try_parse_from(["snarkos", "--view-key", "", "--last", "10", "--endpoint", "localhost"].iter())
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
let config = Scan::try_parse_from(
["snarkos", "--view-key", &format!("{view_key}"), "--last", "10", "--endpoint", "localhost"].iter(),
)
.unwrap();
let (result_pkey, result_vkey) = config.parse_account::<CurrentNetwork>().unwrap();
assert_eq!(result_pkey, None);
assert_eq!(result_vkey, view_key);
let err = Scan::try_parse_from(
[
"snarkos",
"--private-key",
&format!("{private_key}"),
"--view-key",
&format!("{view_key}"),
"--last",
"10",
"--endpoint",
"localhost",
]
.iter(),
)
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn test_parse_block_range() -> Result<()> {
const TEST_VIEW_KEY: &str = "AViewKey1qQVfici7WarfXgmq9iuH8tzRcrWtb8qYq1pEyRRE4kS7";
let config = Scan::try_parse_from(
["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "0", "--end", "10", "--endpoint", "localhost"].iter(),
)?;
let endpoint = Uri::default();
config.parse_block_range::<CurrentNetwork>(&endpoint).with_context(|| "Failed to parse block range")?;
let config = Scan::try_parse_from(
["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "10", "--end", "5", "--endpoint", "localhost"].iter(),
)?;
let endpoint = Uri::default();
assert!(config.parse_block_range::<CurrentNetwork>(&endpoint).is_err());
let err = Scan::try_parse_from(
["snarkos", "--view-key", TEST_VIEW_KEY, "--start", "0", "--last", "10", "--endpoint=localhost"].iter(),
)
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
let err = Scan::try_parse_from(
["snarkos", "--view-key", TEST_VIEW_KEY, "--end", "10", "--last", "10", "--endpoint", "localhost"].iter(),
)
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
let err = Scan::try_parse_from(
[
"snarkos",
"--view-key",
TEST_VIEW_KEY,
"--start",
"0",
"--end",
"01",
"--last",
"10",
"--endpoint",
"localhost",
]
.iter(),
)
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
Ok(())
}
}