Skip to main content

quantus_cli/cli/
multisend.rs

1//! Multisend command - send random amounts to multiple addresses
2//!
3//! Distributes a total amount across multiple recipients with random amounts,
4//! subject to min/max constraints per recipient.
5
6use crate::{
7	chain::client::QuantusClient,
8	cli::{
9		common::{resolve_address, ExecutionMode},
10		send::{
11			batch_transfer, format_balance, format_balance_with_symbol, get_balance,
12			get_chain_properties, parse_amount,
13		},
14	},
15	error::{QuantusError, Result},
16	log_info, log_print, log_success, log_verbose,
17};
18use colored::Colorize;
19use rand::{seq::SliceRandom, Rng};
20use std::{
21	fs,
22	io::{self, Write},
23};
24
25/// Generate a random distribution of amounts across n recipients.
26///
27/// Each amount will be in the range [min, max] and all amounts will sum to exactly `total`.
28///
29/// # Algorithm
30/// 1. Start everyone at the minimum amount
31/// 2. Randomly distribute the remaining amount (total - n*min) across recipients
32/// 3. Shuffle the final amounts to avoid bias toward earlier recipients
33///
34/// # Errors
35/// Returns an error if the constraints are unsatisfiable:
36/// - `n * min > total` (not enough total to give everyone the minimum)
37/// - `n * max < total` (can't fit the total even with everyone at maximum)
38pub fn generate_random_distribution(
39	n: usize,
40	total: u128,
41	min: u128,
42	max: u128,
43) -> Result<Vec<u128>> {
44	if n == 0 {
45		return Err(QuantusError::Generic("Cannot distribute to zero recipients".to_string()));
46	}
47
48	if min > max {
49		return Err(QuantusError::Generic(format!(
50			"Minimum amount ({}) cannot be greater than maximum amount ({})",
51			min, max
52		)));
53	}
54
55	let n_u128 = n as u128;
56	let min_possible = n_u128.saturating_mul(min);
57	let max_possible = n_u128.saturating_mul(max);
58
59	if total < min_possible {
60		return Err(QuantusError::Generic(format!(
61			"Cannot distribute {} among {} recipients with min={}. \
62			 Minimum required total: {}",
63			total, n, min, min_possible
64		)));
65	}
66
67	if total > max_possible {
68		return Err(QuantusError::Generic(format!(
69			"Cannot distribute {} among {} recipients with max={}. \
70			 Maximum possible total: {}",
71			total, n, max, max_possible
72		)));
73	}
74
75	// Start everyone at the minimum
76	let mut amounts: Vec<u128> = vec![min; n];
77	let mut remaining = total - min_possible;
78
79	// Randomly distribute the remaining amount
80	let mut rng = rand::rng();
81
82	while remaining > 0 {
83		// Find recipients who can still receive more
84		let eligible_indices: Vec<usize> = amounts
85			.iter()
86			.enumerate()
87			.filter(|(_, &amt)| amt < max)
88			.map(|(i, _)| i)
89			.collect();
90
91		if eligible_indices.is_empty() {
92			// This shouldn't happen if our math is correct, but safety first
93			break;
94		}
95
96		// Pick a random eligible recipient
97		let recipient_idx = eligible_indices[rng.random_range(0..eligible_indices.len())];
98		let headroom = max - amounts[recipient_idx];
99
100		// Add a random amount (at least 1, at most headroom or remaining)
101		let max_addition = headroom.min(remaining);
102		let amount_to_add = if max_addition == 1 { 1 } else { rng.random_range(1..=max_addition) };
103
104		amounts[recipient_idx] += amount_to_add;
105		remaining -= amount_to_add;
106	}
107
108	// Shuffle to avoid any bias from the distribution order
109	amounts.shuffle(&mut rng);
110
111	// Sanity check
112	let sum: u128 = amounts.iter().sum();
113	debug_assert_eq!(sum, total, "Distribution sum mismatch");
114
115	Ok(amounts)
116}
117
118/// Load addresses from a JSON file.
119///
120/// Expected format: `["addr1", "addr2", "addr3"]`
121pub fn load_addresses_from_file(file_path: &str) -> Result<Vec<String>> {
122	let content = fs::read_to_string(file_path).map_err(|e| {
123		QuantusError::Generic(format!("Failed to read addresses file '{}': {}", file_path, e))
124	})?;
125
126	let addresses: Vec<String> = serde_json::from_str(&content).map_err(|e| {
127		QuantusError::Generic(format!(
128			"Failed to parse addresses file '{}'. Expected JSON array of strings: {}",
129			file_path, e
130		))
131	})?;
132
133	if addresses.is_empty() {
134		return Err(QuantusError::Generic("Addresses file is empty".to_string()));
135	}
136
137	Ok(addresses)
138}
139
140/// Handle the multisend command
141#[allow(clippy::too_many_arguments)]
142pub async fn handle_multisend_command(
143	from_wallet: String,
144	node_url: &str,
145	addresses_file: Option<String>,
146	addresses_inline: Option<Vec<String>>,
147	total_str: String,
148	min_str: String,
149	max_str: String,
150	password: Option<String>,
151	password_file: Option<String>,
152	tip: Option<String>,
153	skip_confirmation: bool,
154	execution_mode: ExecutionMode,
155) -> Result<()> {
156	// Connect to chain
157	let quantus_client = QuantusClient::new(node_url).await?;
158	let (symbol, decimals) = get_chain_properties(&quantus_client).await?;
159
160	// Parse addresses from file or inline
161	let raw_addresses = if let Some(file_path) = addresses_file {
162		load_addresses_from_file(&file_path)?
163	} else if let Some(addrs) = addresses_inline {
164		if addrs.is_empty() {
165			return Err(QuantusError::Generic(
166				"No addresses provided. Use --addresses or --addresses-file".to_string(),
167			));
168		}
169		addrs
170	} else {
171		return Err(QuantusError::Generic(
172			"No addresses provided. Use --addresses or --addresses-file".to_string(),
173		));
174	};
175
176	// Resolve all addresses (could be wallet names or SS58 addresses)
177	let mut resolved_addresses = Vec::with_capacity(raw_addresses.len());
178	for addr in &raw_addresses {
179		let resolved = resolve_address(addr)?;
180		resolved_addresses.push(resolved);
181	}
182
183	let n = resolved_addresses.len();
184	log_verbose!("Resolved {} addresses", n);
185
186	// Parse amounts
187	let total = parse_amount(&quantus_client, &total_str).await?;
188	let min = parse_amount(&quantus_client, &min_str).await?;
189	let max = parse_amount(&quantus_client, &max_str).await?;
190
191	log_verbose!("Parsed amounts - total: {}, min: {}, max: {}", total, min, max);
192
193	// Generate random distribution
194	let amounts = generate_random_distribution(n, total, min, max)?;
195
196	// Create transfers list
197	let transfers: Vec<(String, u128)> =
198		resolved_addresses.iter().cloned().zip(amounts.iter().cloned()).collect();
199
200	// Display preview
201	log_print!("");
202	log_print!("{} Multisend Preview", "===".bright_cyan().bold());
203	log_print!("");
204	log_print!(
205		"  Total amount:  {}",
206		format!("{} {}", format_balance(total, decimals), symbol).bright_yellow().bold()
207	);
208	log_print!("  Recipients:    {}", n.to_string().bright_green());
209	log_print!("  Min per recipient: {} {}", format_balance(min, decimals), symbol);
210	log_print!("  Max per recipient: {} {}", format_balance(max, decimals), symbol);
211	log_print!("");
212
213	// Display table header
214	log_print!("  {:>3} | {:<50} | {:>20}", "#".dimmed(), "Address".dimmed(), "Amount".dimmed());
215	log_print!("  {:-<3}-+-{:-<50}-+-{:-<20}", "", "", "");
216
217	// Display each transfer
218	for (i, (addr, amount)) in transfers.iter().enumerate() {
219		let formatted_amount = format!("{} {}", format_balance(*amount, decimals), symbol);
220
221		// Truncate address for display if needed
222		let display_addr = if addr.len() > 50 {
223			format!("{}...{}", &addr[..24], &addr[addr.len() - 23..])
224		} else {
225			addr.clone()
226		};
227
228		log_print!(
229			"  {:>3} | {:<50} | {:>20}",
230			(i + 1).to_string().bright_white(),
231			display_addr.bright_cyan(),
232			formatted_amount.bright_yellow()
233		);
234	}
235
236	// Display total line
237	log_print!("  {:-<3}-+-{:-<50}-+-{:-<20}", "", "", "");
238	let total_formatted = format!("{} {}", format_balance(total, decimals), symbol);
239	log_print!(
240		"  {:>3} | {:<50} | {:>20}",
241		"",
242		"Total".bold(),
243		total_formatted.bright_green().bold()
244	);
245	log_print!("");
246
247	// Prompt for confirmation unless --yes is passed
248	if !skip_confirmation {
249		print!("Proceed with this transaction? (yes/no): ");
250		io::stdout().flush().unwrap();
251
252		let mut input = String::new();
253		io::stdin().read_line(&mut input).unwrap();
254
255		if input.trim().to_lowercase() != "yes" {
256			log_print!("Multisend cancelled.");
257			return Ok(());
258		}
259		log_print!("");
260	}
261
262	// Send the transaction
263	log_info!("Preparing multisend transaction...");
264
265	// Load wallet
266	let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?;
267	let from_account_id = keypair.to_account_id_ss58check();
268
269	// Check balance
270	let balance = get_balance(&quantus_client, &from_account_id).await?;
271	let estimated_fee = 50_000_000_000u128; // Rough estimate for batch
272
273	if balance < total + estimated_fee {
274		let formatted_balance = format_balance_with_symbol(&quantus_client, balance).await?;
275		let formatted_needed =
276			format_balance_with_symbol(&quantus_client, total + estimated_fee).await?;
277		return Err(QuantusError::Generic(format!(
278			"Insufficient balance. Have: {}, Need: {} (including estimated fees)",
279			formatted_balance, formatted_needed
280		)));
281	}
282
283	// Parse tip if provided
284	let tip_amount = if let Some(tip_str) = tip {
285		Some(parse_amount(&quantus_client, &tip_str).await?)
286	} else {
287		None
288	};
289
290	// Submit batch transaction
291	let tx_hash =
292		batch_transfer(&quantus_client, &keypair, transfers, tip_amount, execution_mode).await?;
293
294	log_print!(
295		"{} Multisend transaction submitted! Hash: {:?}",
296		"SUCCESS".bright_green().bold(),
297		tx_hash
298	);
299
300	log_success!("{} Multisend transaction confirmed!", "FINISHED".bright_green().bold());
301
302	// Show updated balance
303	let new_balance = get_balance(&quantus_client, &from_account_id).await?;
304	let formatted_new_balance = format_balance_with_symbol(&quantus_client, new_balance).await?;
305	log_print!("New balance: {}", formatted_new_balance.bright_yellow());
306
307	Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312	use super::*;
313
314	#[test]
315	fn test_generate_random_distribution_basic() {
316		let amounts = generate_random_distribution(5, 1000, 100, 300).unwrap();
317		assert_eq!(amounts.len(), 5);
318		assert_eq!(amounts.iter().sum::<u128>(), 1000);
319		for &amt in &amounts {
320			assert!((100..=300).contains(&amt), "Amount {} out of range", amt);
321		}
322	}
323
324	#[test]
325	fn test_generate_random_distribution_exact_min() {
326		// Total equals n * min, so everyone gets exactly min
327		let amounts = generate_random_distribution(4, 400, 100, 200).unwrap();
328		assert_eq!(amounts.len(), 4);
329		assert_eq!(amounts.iter().sum::<u128>(), 400);
330		for &amt in &amounts {
331			assert_eq!(amt, 100);
332		}
333	}
334
335	#[test]
336	fn test_generate_random_distribution_exact_max() {
337		// Total equals n * max, so everyone gets exactly max
338		let amounts = generate_random_distribution(4, 800, 100, 200).unwrap();
339		assert_eq!(amounts.len(), 4);
340		assert_eq!(amounts.iter().sum::<u128>(), 800);
341		for &amt in &amounts {
342			assert_eq!(amt, 200);
343		}
344	}
345
346	#[test]
347	fn test_generate_random_distribution_single_recipient() {
348		let amounts = generate_random_distribution(1, 500, 100, 600).unwrap();
349		assert_eq!(amounts.len(), 1);
350		assert_eq!(amounts[0], 500);
351	}
352
353	#[test]
354	fn test_generate_random_distribution_total_too_small() {
355		let result = generate_random_distribution(5, 400, 100, 200);
356		assert!(result.is_err());
357		assert!(result.unwrap_err().to_string().contains("Minimum required"));
358	}
359
360	#[test]
361	fn test_generate_random_distribution_total_too_large() {
362		let result = generate_random_distribution(5, 1500, 100, 200);
363		assert!(result.is_err());
364		assert!(result.unwrap_err().to_string().contains("Maximum possible"));
365	}
366
367	#[test]
368	fn test_generate_random_distribution_min_greater_than_max() {
369		let result = generate_random_distribution(5, 1000, 300, 100);
370		assert!(result.is_err());
371		assert!(result.unwrap_err().to_string().contains("cannot be greater than"));
372	}
373
374	#[test]
375	fn test_generate_random_distribution_zero_recipients() {
376		let result = generate_random_distribution(0, 1000, 100, 200);
377		assert!(result.is_err());
378		assert!(result.unwrap_err().to_string().contains("zero recipients"));
379	}
380
381	#[test]
382	fn test_distribution_randomness() {
383		// Run multiple times and check that we get different distributions
384		let mut seen_distributions = std::collections::HashSet::new();
385		for _ in 0..10 {
386			let amounts = generate_random_distribution(5, 1000, 100, 300).unwrap();
387			seen_distributions.insert(format!("{:?}", amounts));
388		}
389		// With 5 recipients and a decent range, we should see some variety
390		// (though this isn't guaranteed - it's probabilistic)
391		assert!(seen_distributions.len() > 1, "Expected multiple different distributions");
392	}
393}