nym_cli_commands/validator/account/
send_multiple.rs

1// Copyright 2023 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use 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            // try parse amount to u128
164            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            // multiply when a whole token amount, e.g. 50nym (50.123456nym is not allowed, that must be input as 50123456unym)
170            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}