1use 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
25pub 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 let mut amounts: Vec<u128> = vec![min; n];
77 let mut remaining = total - min_possible;
78
79 let mut rng = rand::rng();
81
82 while remaining > 0 {
83 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 break;
94 }
95
96 let recipient_idx = eligible_indices[rng.random_range(0..eligible_indices.len())];
98 let headroom = max - amounts[recipient_idx];
99
100 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 amounts.shuffle(&mut rng);
110
111 let sum: u128 = amounts.iter().sum();
113 debug_assert_eq!(sum, total, "Distribution sum mismatch");
114
115 Ok(amounts)
116}
117
118pub 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#[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 let quantus_client = QuantusClient::new(node_url).await?;
158 let (symbol, decimals) = get_chain_properties(&quantus_client).await?;
159
160 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 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 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 let amounts = generate_random_distribution(n, total, min, max)?;
195
196 let transfers: Vec<(String, u128)> =
198 resolved_addresses.iter().cloned().zip(amounts.iter().cloned()).collect();
199
200 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 log_print!(" {:>3} | {:<50} | {:>20}", "#".dimmed(), "Address".dimmed(), "Amount".dimmed());
215 log_print!(" {:-<3}-+-{:-<50}-+-{:-<20}", "", "", "");
216
217 for (i, (addr, amount)) in transfers.iter().enumerate() {
219 let formatted_amount = format!("{} {}", format_balance(*amount, decimals), symbol);
220
221 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 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 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 log_info!("Preparing multisend transaction...");
264
265 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 let balance = get_balance(&quantus_client, &from_account_id).await?;
271 let estimated_fee = 50_000_000_000u128; 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 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 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 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 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 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 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 assert!(seen_distributions.len() > 1, "Expected multiple different distributions");
392 }
393}