use crate::{
cli::send::{format_balance, get_chain_properties},
error::{QuantusError, Result},
log_error, log_print, log_success, log_verbose,
subsquid::{
compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams,
},
wallet::WalletManager,
};
use clap::Subcommand;
use colored::Colorize;
use sp_core::crypto::{AccountId32, Ss58Codec};
#[derive(Subcommand, Debug)]
pub enum TransfersCommands {
Query {
#[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")]
subsquid_url: String,
#[arg(long, default_value = "4")]
prefix_len: usize,
#[arg(long)]
after_block: Option<u32>,
#[arg(long)]
before_block: Option<u32>,
#[arg(long)]
min_amount: Option<u128>,
#[arg(long, default_value = "100")]
limit: u32,
#[arg(long)]
wallet: Option<String>,
#[arg(long)]
json: bool,
},
HashAddress {
address: String,
#[arg(long, default_value = "4")]
prefix_len: usize,
},
}
pub async fn handle_transfers_command(cmd: TransfersCommands, node_url: &str) -> Result<()> {
match cmd {
TransfersCommands::Query {
subsquid_url,
prefix_len,
after_block,
before_block,
min_amount,
limit,
wallet,
json,
} =>
handle_query_command(
subsquid_url,
prefix_len,
after_block,
before_block,
min_amount,
limit,
wallet,
json,
node_url,
)
.await,
TransfersCommands::HashAddress { address, prefix_len } =>
handle_hash_address_command(&address, prefix_len),
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_query_command(
subsquid_url: String,
prefix_len: usize,
after_block: Option<u32>,
before_block: Option<u32>,
min_amount: Option<u128>,
limit: u32,
wallet_name: Option<String>,
json_output: bool,
node_url: &str,
) -> Result<()> {
if prefix_len == 0 || prefix_len > 64 {
return Err(QuantusError::Generic("Prefix length must be between 1 and 64".to_string()));
}
let wallet_manager = WalletManager::new()?;
let wallets = wallet_manager.list_wallets()?;
if wallets.is_empty() {
log_error!("No wallets found. Create a wallet first with 'quantus wallet create'");
return Ok(());
}
let wallets_to_query: Vec<_> = if let Some(name) = &wallet_name {
wallets.into_iter().filter(|w| w.name == *name).collect()
} else {
wallets
};
if wallets_to_query.is_empty() {
log_error!("No matching wallet found");
return Ok(());
}
let mut raw_addresses: Vec<[u8; 32]> = Vec::new();
for wallet in &wallets_to_query {
let account_id = AccountId32::from_ss58check(&wallet.address).map_err(|e| {
QuantusError::Generic(format!("Invalid address {}: {}", wallet.address, e))
})?;
raw_addresses.push(account_id.into());
}
if !json_output {
log_print!("{}", "Privacy-Preserving Transfer Query".bright_cyan().bold());
log_print!("");
log_print!(
" Querying for {} wallet(s) with prefix length {}",
wallets_to_query.len().to_string().bright_yellow(),
prefix_len.to_string().bright_yellow()
);
log_print!(
" Privacy level: ~1/{} of address space per query",
(1u64 << (prefix_len * 4)).to_string().bright_green()
);
log_print!("");
}
let client = SubsquidClient::new(subsquid_url)?;
let mut params = TransferQueryParams::new().with_limit(limit);
if let Some(block) = after_block {
params = params.with_after_block(block);
}
if let Some(block) = before_block {
params = params.with_before_block(block);
}
if let Some(amount) = min_amount {
params = params.with_min_amount(amount);
}
let transfers =
client.query_transfers_for_addresses(&raw_addresses, prefix_len, params).await?;
if json_output {
let json = serde_json::to_string_pretty(&transfers)
.map_err(|e| QuantusError::Generic(format!("Failed to serialize transfers: {}", e)))?;
println!("{}", json);
} else {
if transfers.is_empty() {
log_print!("No transfers found for your addresses.");
} else {
let parsed_transfers: Vec<_> = transfers
.iter()
.map(|transfer| {
Ok((
transfer,
parse_transfer_amount(transfer)?,
transfer_timestamp_prefix(transfer)?,
))
})
.collect::<Result<_>>()?;
let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?;
let (symbol, decimals) = get_chain_properties(&quantus_client).await?;
log_success!("Found {} transfers:", transfers.len().to_string().bright_green());
log_print!("");
for (transfer, amount, timestamp) in parsed_transfers {
let our_address_hashes: std::collections::HashSet<String> =
raw_addresses.iter().map(compute_address_hash).collect();
let is_incoming = our_address_hashes.contains(&transfer.to_hash);
let is_outgoing = our_address_hashes.contains(&transfer.from_hash);
let direction = match (is_incoming, is_outgoing) {
(true, true) => "SELF".bright_blue(),
(true, false) => "IN".bright_green(),
(false, true) => "OUT".bright_red(),
(false, false) => "???".dimmed(), };
let formatted_amount = format!("{} {}", format_balance(amount, decimals), symbol);
log_print!(
" [{}] {} | Block {} | {} | {} -> {}",
direction,
timestamp, transfer.block_height.to_string().bright_yellow(),
formatted_amount.bright_cyan(),
truncate_address(&transfer.from_id),
truncate_address(&transfer.to_id),
);
if let Some(hash) = &transfer.extrinsic_hash {
log_verbose!(" Extrinsic: {}", hash.dimmed());
}
}
}
}
Ok(())
}
fn parse_transfer_amount(transfer: &Transfer) -> Result<u128> {
transfer.amount.parse().map_err(|_| {
QuantusError::Generic(format!(
"Invalid transfer amount from indexer for transfer {}: '{}'",
transfer.id, transfer.amount
))
})
}
fn transfer_timestamp_prefix(transfer: &Transfer) -> Result<&str> {
transfer.timestamp.get(..19).ok_or_else(|| {
QuantusError::Generic(format!(
"Invalid transfer timestamp from indexer for transfer {}: '{}'",
transfer.id, transfer.timestamp
))
})
}
fn handle_hash_address_command(address: &str, prefix_len: usize) -> Result<()> {
let account_id = AccountId32::from_ss58check(address)
.map_err(|e| QuantusError::Generic(format!("Invalid address: {}", e)))?;
let raw_address: [u8; 32] = account_id.into();
let full_hash = compute_address_hash(&raw_address);
let prefix = get_hash_prefix(&full_hash, prefix_len);
log_print!("{}", "Address Hash Information".bright_cyan().bold());
log_print!("");
log_print!(" Address: {}", address.bright_yellow());
log_print!(" Full Hash: {}", full_hash.dimmed());
log_print!(" Prefix ({}): {}", prefix_len, prefix.bright_green().bold());
log_print!("");
log_print!(
" Privacy: With prefix length {}, your query will match ~1/{} of all addresses",
prefix_len,
(1u64 << (prefix_len * 4)).to_string().bright_cyan()
);
Ok(())
}
fn truncate_address(address: &str) -> String {
if address.len() > 16 {
format!("{}...{}", &address[..8], &address[address.len() - 6..])
} else {
address.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_transfer(amount: &str, timestamp: &str) -> Transfer {
Transfer {
id: "t1".to_string(),
block_id: "b1".to_string(),
block_height: 1,
timestamp: timestamp.to_string(),
extrinsic_hash: None,
from_id: "from".to_string(),
to_id: "to".to_string(),
amount: amount.to_string(),
fee: "0".to_string(),
from_hash: "aa".to_string(),
to_hash: "bb".to_string(),
leaf_index: "0".to_string(),
transfer_count: "0".to_string(),
}
}
#[test]
fn parse_transfer_amount_rejects_invalid_values() {
let bad = sample_transfer("not-a-number", "2024-01-01T00:00:00.000Z");
let err = parse_transfer_amount(&bad).unwrap_err();
assert!(err.to_string().contains("Invalid transfer amount"), "unexpected error: {err}");
assert_eq!(
parse_transfer_amount(&sample_transfer("12345", "2024-01-01T00:00:00.000Z")).unwrap(),
12345
);
}
#[test]
fn transfer_timestamp_prefix_rejects_short_timestamps() {
let short = sample_transfer("1", "short");
let err = transfer_timestamp_prefix(&short).unwrap_err();
assert!(err.to_string().contains("Invalid transfer timestamp"), "unexpected error: {err}");
assert_eq!(
transfer_timestamp_prefix(&sample_transfer("1", "2024-01-01T00:00:00.000Z")).unwrap(),
"2024-01-01T00:00:00"
);
}
}