#![allow(clippy::type_complexity)]
use super::CurrentNetwork;
use snarkvm::prelude::{block::Block, Ciphertext, Field, FromBytes, Network, Plaintext, PrivateKey, Record, ViewKey};
use anyhow::{bail, ensure, Result};
use clap::Parser;
use parking_lot::RwLock;
use std::{
io::{stdout, Write},
str::FromStr,
sync::Arc,
};
const MAX_BLOCK_RANGE: u32 = 50;
const CDN_ENDPOINT: &str = "https://s3.us-west-1.amazonaws.com/testnet3.blocks/phase3";
#[derive(Debug, Parser)]
pub struct Scan {
#[clap(short, long)]
private_key: Option<String>,
#[clap(short, long)]
view_key: Option<String>,
#[clap(long, conflicts_with = "last")]
start: Option<u32>,
#[clap(long, conflicts_with = "last")]
end: Option<u32>,
#[clap(long)]
last: Option<u32>,
#[clap(long)]
endpoint: String,
}
impl Scan {
pub fn parse(self) -> Result<String> {
let (private_key, view_key) = self.parse_account()?;
let (start_height, end_height) = self.parse_block_range()?;
let records = Self::fetch_records(private_key, &view_key, &self.endpoint, start_height, end_height)?;
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>)> {
match (&self.private_key, &self.view_key) {
(Some(private_key), Some(view_key)) => {
let private_key = PrivateKey::<N>::from_str(private_key)?;
let expected_view_key = ViewKey::<N>::try_from(private_key)?;
let view_key = ViewKey::<N>::from_str(view_key)?;
ensure!(
expected_view_key == view_key,
"The provided private key does not correspond to the provided view key."
);
Ok((Some(private_key), view_key))
}
(Some(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))
}
(None, Some(view_key)) => Ok((None, ViewKey::<N>::from_str(view_key)?)),
(None, None) => bail!("Missing private key or view key."),
}
}
fn parse_block_range(&self) -> Result<(u32, u32)> {
match (self.start, self.end, self.last) {
(Some(start), Some(end), None) => {
ensure!(end > start, "The given scan range is invalid (start = {start}, end = {end})");
Ok((start, end))
}
(Some(start), None, None) => {
let endpoint = format!("{}/testnet3/latest/height", self.endpoint);
let latest_height = u32::from_str(&ureq::get(&endpoint).call()?.into_string()?)?;
if start == 0 {
println!("⚠️ Attention - Scanning the entire chain. This may take a while...\n");
}
Ok((start, latest_height))
}
(None, Some(end), None) => Ok((0, end)),
(None, None, Some(last)) => {
let endpoint = format!("{}/testnet3/latest/height", self.endpoint);
let latest_height = u32::from_str(&ureq::get(&endpoint).call()?.into_string()?)?;
Ok((latest_height.saturating_sub(last), latest_height))
}
(None, None, None) => bail!("Missing data about block range."),
_ => bail!("`last` flags can't be used with `start` or `end`"),
}
}
fn fetch_records(
private_key: Option<PrivateKey<CurrentNetwork>>,
view_key: &ViewKey<CurrentNetwork>,
endpoint: &str,
start_height: u32,
end_height: u32,
) -> Result<Vec<Record<CurrentNetwork, Plaintext<CurrentNetwork>>>> {
if start_height > end_height {
bail!("Invalid block range");
}
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()?;
let genesis_block: Block<CurrentNetwork> =
ureq::get(&format!("{endpoint}/testnet3/block/0")).call()?.into_json()?;
let is_development_network = genesis_block != Block::from_bytes_le(CurrentNetwork::genesis_bytes())?;
let mut request_start = match is_development_network {
true => start_height,
false => {
Self::scan_from_cdn(
start_height,
end_height,
CDN_ENDPOINT.to_string(),
endpoint.to_string(),
private_key,
*view_key,
address_x_coordinate,
records.clone(),
)?;
end_height.saturating_sub(start_height % MAX_BLOCK_RANGE)
}
};
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_endpoint = format!("{endpoint}/testnet3/blocks?start={request_start}&end={request_end}");
let blocks: Vec<Block<CurrentNetwork>> = ureq::get(&blocks_endpoint).call()?.into_json()?;
for block in &blocks {
Self::scan_block(block, endpoint, private_key, view_key, &address_x_coordinate, records.clone())?;
}
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)]
fn scan_from_cdn(
start_height: u32,
end_height: u32,
cdn: String,
endpoint: String,
private_key: Option<PrivateKey<CurrentNetwork>>,
view_key: ViewKey<CurrentNetwork>,
address_x_coordinate: Field<CurrentNetwork>,
records: Arc<RwLock<Vec<Record<CurrentNetwork, Plaintext<CurrentNetwork>>>>>,
) -> Result<()> {
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(start_height % MAX_BLOCK_RANGE);
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async move {
let _ = snarkos_node_cdn::load_blocks(&cdn, cdn_request_start, Some(cdn_request_end), 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;
});
Ok(())
}
fn scan_block(
block: &Block<CurrentNetwork>,
endpoint: &str,
private_key: Option<PrivateKey<CurrentNetwork>>,
view_key: &ViewKey<CurrentNetwork>,
address_x_coordinate: &Field<CurrentNetwork>,
records: Arc<RwLock<Vec<Record<CurrentNetwork, Plaintext<CurrentNetwork>>>>>,
) -> 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)?
{
records.write().push(record);
}
}
}
Ok(())
}
fn decrypt_record(
private_key: Option<PrivateKey<CurrentNetwork>>,
view_key: &ViewKey<CurrentNetwork>,
endpoint: &str,
commitment: Field<CurrentNetwork>,
ciphertext_record: &Record<CurrentNetwork, Ciphertext<CurrentNetwork>>,
) -> Result<Option<Record<CurrentNetwork, Plaintext<CurrentNetwork>>>> {
if let Some(private_key) = private_key {
let serial_number =
Record::<CurrentNetwork, Plaintext<CurrentNetwork>>::serial_number(private_key, commitment)?;
let endpoint = format!("{endpoint}/testnet3/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::{TestRng, Testnet3};
type CurrentNetwork = Testnet3;
#[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 unassociated_private_key = PrivateKey::<CurrentNetwork>::new(rng).unwrap();
let unassociated_view_key = ViewKey::try_from(unassociated_private_key).unwrap();
let config = Scan::try_parse_from(
[
"snarkos",
"--private-key",
&format!("{private_key}"),
"--view-key",
&format!("{view_key}"),
"--last",
"10",
"--endpoint",
"",
]
.iter(),
)
.unwrap();
assert!(config.parse_account::<CurrentNetwork>().is_ok());
let config = Scan::try_parse_from(
[
"snarkos",
"--private-key",
&format!("{private_key}"),
"--view-key",
&format!("{unassociated_view_key}"),
"--last",
"10",
"--endpoint",
"",
]
.iter(),
)
.unwrap();
assert!(config.parse_account::<CurrentNetwork>().is_err());
}
#[test]
fn test_parse_block_range() {
let config =
Scan::try_parse_from(["snarkos", "--view-key", "", "--start", "0", "--end", "10", "--endpoint", ""].iter())
.unwrap();
assert!(config.parse_block_range().is_ok());
let config =
Scan::try_parse_from(["snarkos", "--view-key", "", "--start", "10", "--end", "5", "--endpoint", ""].iter())
.unwrap();
assert!(config.parse_block_range().is_err());
assert!(
Scan::try_parse_from(
["snarkos", "--view-key", "", "--start", "0", "--last", "10", "--endpoint", ""].iter(),
)
.is_err()
);
assert!(
Scan::try_parse_from(["snarkos", "--view-key", "", "--end", "10", "--last", "10", "--endpoint", ""].iter())
.is_err()
);
assert!(
Scan::try_parse_from(
["snarkos", "--view-key", "", "--start", "0", "--end", "01", "--last", "10", "--endpoint", ""].iter(),
)
.is_err()
);
}
}