Skip to main content

quantus_cli/cli/
transfers.rs

1//! Privacy-preserving transfer queries via Subsquid indexer.
2//!
3//! This module provides commands for querying transfers using hash prefix queries,
4//! which allows clients to retrieve their transactions without revealing their
5//! exact addresses to the indexer.
6
7use crate::{
8	cli::send::format_balance,
9	error::{QuantusError, Result},
10	log_error, log_print, log_success, log_verbose,
11	subsquid::{compute_address_hash, get_hash_prefix, SubsquidClient, TransferQueryParams},
12	wallet::WalletManager,
13};
14use clap::Subcommand;
15use colored::Colorize;
16use sp_core::crypto::{AccountId32, Ss58Codec};
17
18/// Transfers subcommands
19#[derive(Subcommand, Debug)]
20pub enum TransfersCommands {
21	/// Query transfers for your wallet addresses using privacy-preserving hash prefix queries
22	Query {
23		/// Subsquid indexer URL (e.g., "https://indexer.quantus.com/graphql")
24		#[arg(long)]
25		subsquid_url: String,
26
27		/// Hash prefix length in hex characters (1-64).
28		/// Shorter = more privacy but more noise, longer = less privacy but fewer false positives.
29		/// Default: 4 (1/65536 of address space per prefix)
30		#[arg(long, default_value = "4")]
31		prefix_len: usize,
32
33		/// Only show transfers after this block number
34		#[arg(long)]
35		after_block: Option<u32>,
36
37		/// Only show transfers before this block number
38		#[arg(long)]
39		before_block: Option<u32>,
40
41		/// Minimum transfer amount (in smallest unit, e.g., planck)
42		#[arg(long)]
43		min_amount: Option<u128>,
44
45		/// Maximum number of results (default: 100, max: 1000)
46		#[arg(long, default_value = "100")]
47		limit: u32,
48
49		/// Specific wallet name to query for (if not provided, queries all wallets)
50		#[arg(long)]
51		wallet: Option<String>,
52
53		/// Show raw transfer data as JSON
54		#[arg(long)]
55		json: bool,
56	},
57
58	/// Compute the hash prefix for an address (for debugging/testing)
59	HashAddress {
60		/// The address to hash (SS58 format)
61		address: String,
62
63		/// Prefix length to display
64		#[arg(long, default_value = "4")]
65		prefix_len: usize,
66	},
67}
68
69/// Handle transfers commands
70pub async fn handle_transfers_command(cmd: TransfersCommands) -> Result<()> {
71	match cmd {
72		TransfersCommands::Query {
73			subsquid_url,
74			prefix_len,
75			after_block,
76			before_block,
77			min_amount,
78			limit,
79			wallet,
80			json,
81		} =>
82			handle_query_command(
83				subsquid_url,
84				prefix_len,
85				after_block,
86				before_block,
87				min_amount,
88				limit,
89				wallet,
90				json,
91			)
92			.await,
93		TransfersCommands::HashAddress { address, prefix_len } =>
94			handle_hash_address_command(&address, prefix_len),
95	}
96}
97
98/// Handle the query subcommand
99#[allow(clippy::too_many_arguments)]
100async fn handle_query_command(
101	subsquid_url: String,
102	prefix_len: usize,
103	after_block: Option<u32>,
104	before_block: Option<u32>,
105	min_amount: Option<u128>,
106	limit: u32,
107	wallet_name: Option<String>,
108	json_output: bool,
109) -> Result<()> {
110	// Validate prefix length
111	if prefix_len == 0 || prefix_len > 64 {
112		return Err(QuantusError::Generic("Prefix length must be between 1 and 64".to_string()));
113	}
114
115	// Load wallet addresses
116	let wallet_manager = WalletManager::new()?;
117	let wallets = wallet_manager.list_wallets()?;
118
119	if wallets.is_empty() {
120		log_error!("No wallets found. Create a wallet first with 'quantus wallet create'");
121		return Ok(());
122	}
123
124	// Filter to specific wallet if requested
125	let wallets_to_query: Vec<_> = if let Some(name) = &wallet_name {
126		wallets.into_iter().filter(|w| w.name == *name).collect()
127	} else {
128		wallets
129	};
130
131	if wallets_to_query.is_empty() {
132		log_error!("No matching wallet found");
133		return Ok(());
134	}
135
136	// Convert SS58 addresses to raw account IDs
137	let mut raw_addresses: Vec<[u8; 32]> = Vec::new();
138	for wallet in &wallets_to_query {
139		let account_id = AccountId32::from_ss58check(&wallet.address).map_err(|e| {
140			QuantusError::Generic(format!("Invalid address {}: {}", wallet.address, e))
141		})?;
142		raw_addresses.push(account_id.into());
143	}
144
145	if !json_output {
146		log_print!("{}", "Privacy-Preserving Transfer Query".bright_cyan().bold());
147		log_print!("");
148		log_print!(
149			"  Querying for {} wallet(s) with prefix length {}",
150			wallets_to_query.len().to_string().bright_yellow(),
151			prefix_len.to_string().bright_yellow()
152		);
153		log_print!(
154			"  Privacy level: ~1/{} of address space per query",
155			(1u64 << (prefix_len * 4)).to_string().bright_green()
156		);
157		log_print!("");
158	}
159
160	// Create Subsquid client
161	let client = SubsquidClient::new(subsquid_url)?;
162
163	// Build query params
164	let mut params = TransferQueryParams::new().with_limit(limit);
165	if let Some(block) = after_block {
166		params = params.with_after_block(block);
167	}
168	if let Some(block) = before_block {
169		params = params.with_before_block(block);
170	}
171	if let Some(amount) = min_amount {
172		params = params.with_min_amount(amount);
173	}
174
175	// Query transfers
176	let transfers =
177		client.query_transfers_for_addresses(&raw_addresses, prefix_len, params).await?;
178
179	if json_output {
180		// Output as JSON
181		let json = serde_json::to_string_pretty(&transfers)
182			.map_err(|e| QuantusError::Generic(format!("Failed to serialize transfers: {}", e)))?;
183		println!("{}", json);
184	} else {
185		// Display formatted output
186		if transfers.is_empty() {
187			log_print!("No transfers found for your addresses.");
188		} else {
189			log_success!("Found {} transfers:", transfers.len().to_string().bright_green());
190			log_print!("");
191
192			for transfer in &transfers {
193				// Determine if this is incoming or outgoing
194				let our_address_hashes: std::collections::HashSet<String> =
195					raw_addresses.iter().map(compute_address_hash).collect();
196
197				let is_incoming = our_address_hashes.contains(&transfer.to_hash);
198				let is_outgoing = our_address_hashes.contains(&transfer.from_hash);
199
200				let direction = match (is_incoming, is_outgoing) {
201					(true, true) => "SELF".bright_blue(),
202					(true, false) => "IN".bright_green(),
203					(false, true) => "OUT".bright_red(),
204					(false, false) => "???".dimmed(), // Shouldn't happen
205				};
206
207				// Parse and format amount (12 decimals is standard for Substrate)
208				let amount: u128 = transfer.amount.parse().unwrap_or(0);
209				let formatted_amount = format!("{} DEV", format_balance(amount, 12));
210
211				log_print!(
212					"  [{}] {} | Block {} | {} | {} -> {}",
213					direction,
214					&transfer.timestamp[..19], // Truncate to YYYY-MM-DDTHH:MM:SS
215					transfer.block_height.to_string().bright_yellow(),
216					formatted_amount.bright_cyan(),
217					truncate_address(&transfer.from_id),
218					truncate_address(&transfer.to_id),
219				);
220
221				if let Some(hash) = &transfer.extrinsic_hash {
222					log_verbose!("       Extrinsic: {}", hash.dimmed());
223				}
224			}
225		}
226	}
227
228	Ok(())
229}
230
231/// Handle the hash-address subcommand
232fn handle_hash_address_command(address: &str, prefix_len: usize) -> Result<()> {
233	// Parse the SS58 address
234	let account_id = AccountId32::from_ss58check(address)
235		.map_err(|e| QuantusError::Generic(format!("Invalid address: {}", e)))?;
236
237	let raw_address: [u8; 32] = account_id.into();
238	let full_hash = compute_address_hash(&raw_address);
239	let prefix = get_hash_prefix(&full_hash, prefix_len);
240
241	log_print!("{}", "Address Hash Information".bright_cyan().bold());
242	log_print!("");
243	log_print!("  Address:     {}", address.bright_yellow());
244	log_print!("  Full Hash:   {}", full_hash.dimmed());
245	log_print!("  Prefix ({}): {}", prefix_len, prefix.bright_green().bold());
246	log_print!("");
247	log_print!(
248		"  Privacy: With prefix length {}, your query will match ~1/{} of all addresses",
249		prefix_len,
250		(1u64 << (prefix_len * 4)).to_string().bright_cyan()
251	);
252
253	Ok(())
254}
255
256/// Truncate an address for display
257fn truncate_address(address: &str) -> String {
258	if address.len() > 16 {
259		format!("{}...{}", &address[..8], &address[address.len() - 6..])
260	} else {
261		address.to_string()
262	}
263}