nym_cli_commands/validator/account/
send_multiple.rs1use crate::context::SigningClient;
5use crate::utils::pretty_coin;
6use clap::Parser;
7use comfy_table::Table;
8use cosmrs::rpc::endpoint::tx::Response;
9use log::{error, info};
10use nym_validator_client::nyxd::{AccountId, Coin};
11use serde_json::json;
12use std::str::FromStr;
13use std::{fs, io::Write};
14
15#[derive(Debug, Parser)]
16pub struct Args {
17 #[clap(long)]
18 pub memo: Option<String>,
19
20 #[clap(
21 long,
22 help = "Input file path (CSV format) with account/amount pairs to send"
23 )]
24 pub input: String,
25
26 #[clap(
27 long,
28 help = "An output file path (CSV format) to create or append a log of results to"
29 )]
30 pub output: Option<String>,
31}
32
33pub async fn send_multiple(args: Args, client: &SigningClient) {
34 let memo = args
35 .memo
36 .unwrap_or_else(|| "Sending tokens with nym-cli".to_owned());
37
38 let rows = InputFileReader::new(&args.input);
39 if let Err(e) = rows {
40 error!("Failed to read input file: {e}");
41 return;
42 }
43 let rows = rows.unwrap();
44
45 let mut table = Table::new();
46
47 if rows.rows.is_empty() {
48 error!("No transactions to send");
49 return;
50 }
51
52 println!(
53 "The following transfer will be made from account {} to:",
54 client.address()
55 );
56 table.set_header(vec!["Address", "Amount"]);
57
58 for row in rows.rows.iter() {
59 table.add_row(vec![row.address.to_string(), pretty_coin(&row.amount)]);
60 }
61
62 println!("{table}");
63
64 let ans = inquire::Confirm::new("Do you want to continue with the transfers?")
65 .with_default(false)
66 .with_help_message("You must confirm before the transaction will be sent")
67 .prompt();
68
69 if let Err(e) = ans {
70 info!("Aborting, {e}...");
71 return;
72 }
73 if let Ok(false) = ans {
74 info!("Aborting!");
75 return;
76 }
77
78 info!("Transferring from {}...", client.address());
79
80 let multiple_sends: Vec<(AccountId, Vec<Coin>)> = rows
81 .rows
82 .iter()
83 .map(|row| (row.address.clone(), vec![row.amount.clone()]))
84 .collect();
85
86 let res = client
87 .send_multiple(multiple_sends, memo, None)
88 .await
89 .expect("failed to send tokens!");
90
91 info!("Sending result: {}", json!(res));
92
93 println!();
94 println!(
95 "Nodesguru: https://nym.explorers.guru/transaction/{}",
96 &res.hash
97 );
98 println!("Mintscan: https://ping.pub/nyx/tx/{}", &res.hash);
99 println!("Transaction result code: {}", &res.tx_result.code.value());
100 println!("Transaction hash: {}", &res.hash);
101
102 if let Some(output_filename) = args.output {
103 println!("\nWriting output log to {output_filename}");
104
105 if let Err(e) = write_output_file(rows, res, &output_filename) {
106 error!("Failed to write output file {output_filename} with error {e}");
107 }
108 }
109}
110
111fn write_output_file(
112 rows: InputFileReader,
113 res: Response,
114 output_filename: &String,
115) -> Result<(), anyhow::Error> {
116 let mut file = fs::OpenOptions::new()
117 .create(true)
118 .append(true)
119 .open(output_filename)?;
120
121 let now = time::OffsetDateTime::now_utc();
122 let now = now.format(&time::format_description::well_known::Rfc3339)?;
123
124 let data = rows
125 .rows
126 .iter()
127 .map(|row| {
128 format!(
129 "{},{},{},{},{}",
130 row.address, row.amount.amount, row.amount.denom, now, res.hash
131 )
132 })
133 .collect::<Vec<String>>()
134 .join("\n");
135
136 Ok(file.write_all(format!("{data}\n").as_bytes())?)
137}
138
139#[derive(Debug)]
140pub struct InputFileRow {
141 pub address: AccountId,
142 pub amount: Coin,
143}
144
145pub struct InputFileReader {
146 pub rows: Vec<InputFileRow>,
147}
148
149impl InputFileReader {
150 pub fn new(path: &str) -> Result<InputFileReader, anyhow::Error> {
151 let mut rows: Vec<InputFileRow> = vec![];
152 let file_contents = fs::read_to_string(path)?;
153
154 let lines: Vec<String> = file_contents.lines().map(String::from).collect();
155 for line in lines {
156 let tokens: Vec<_> = line.split(',').collect();
157 if tokens.len() < 3 {
158 return Err(anyhow::anyhow!(
159 "'{}' does not have enough columns, expecting <address>,<amount>,<denom>",
160 line
161 ));
162 }
163 let amount = u128::from_str(tokens[1])
165 .map_err(|_| anyhow::anyhow!("'{}' has an invalid amount", line))?;
166
167 let denom: String = tokens[2].into();
168
169 let (amount, denom) = if !denom.starts_with('u') {
171 (amount * 1_000_000u128, format!("u{denom}"))
172 } else {
173 (amount, denom)
174 };
175
176 let address = AccountId::from_str(tokens[0])
177 .map_err(|e| anyhow::anyhow!("'{}' has an invalid address: {}", line, e))?;
178
179 let amount = Coin { amount, denom };
180
181 rows.push(InputFileRow { address, amount })
182 }
183
184 Ok(InputFileReader { rows })
185 }
186}
187
188#[cfg(test)]
189mod test_multiple_send_input_csv {
190 use super::*;
191
192 #[test]
193 fn works_on_happy_path() {
194 let input_csv = InputFileReader::new("fixtures/test_send_multiple.csv").unwrap();
195 assert_eq!(
196 AccountId::from_str("n1q85lscptz860j3dx92f8phaeaw08j2l5dt7adq").unwrap(),
197 input_csv.rows[0].address
198 );
199
200 println!("{:?}", input_csv.rows);
201
202 assert_eq!(50_000_000u128, input_csv.rows[0].amount.amount);
203 assert_eq!(50u128, input_csv.rows[1].amount.amount);
204 assert_eq!(50_000_000u128, input_csv.rows[2].amount.amount);
205 assert_eq!(50u128, input_csv.rows[3].amount.amount);
206
207 assert_eq!("unym", input_csv.rows[0].amount.denom);
208 assert_eq!("unym", input_csv.rows[1].amount.denom);
209 assert_eq!("unyx", input_csv.rows[2].amount.denom);
210 assert_eq!("unyx", input_csv.rows[3].amount.denom);
211 }
212}