diff --git a/Cargo.lock b/Cargo.lock index 3b34595141..3560daedd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,7 +1471,7 @@ version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e959d788268e3bf9d35ace83e81b124190378e4c91c9067524675e33394b8ba" dependencies = [ - "crossterm", + "crossterm 0.26.1", "strum 0.24.1", "strum_macros 0.24.3", "unicode-width", @@ -1932,6 +1932,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.26.1" @@ -2018,6 +2034,27 @@ dependencies = [ "subtle 2.4.1", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -4123,6 +4160,22 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "inquire" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33e7c1ddeb15c9abcbfef6029d8e29f69b52b6d6c891031b88ed91b5065803b" +dependencies = [ + "bitflags 1.3.2", + "crossterm 0.25.0", + "dyn-clone", + "lazy_static", + "newline-converter", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "instant" version = "0.1.12" @@ -5796,6 +5849,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "newline-converter" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nix" version = "0.24.3" @@ -6119,6 +6181,7 @@ dependencies = [ "clap_complete", "clap_complete_fig", "dotenvy", + "inquire", "log", "nym-bin-common", "nym-cli-commands", @@ -6140,13 +6203,16 @@ dependencies = [ "bip39", "bs58 0.4.0", "cfg-if", + "chrono", "clap 4.4.7", "comfy-table", "cosmrs 0.15.0 (git+https://github.com/jstuczyn/cosmos-rust?branch=nym-temp/all-validator-features)", "cosmwasm-std", + "csv", "cw-utils", "handlebars", "humantime-serde", + "inquire", "k256", "log", "nym-bandwidth-controller", diff --git a/common/commands/Cargo.toml b/common/commands/Cargo.toml index 6d4133acc4..a8e665acb4 100644 --- a/common/commands/Cargo.toml +++ b/common/commands/Cargo.toml @@ -13,9 +13,11 @@ bs58 = "0.4" comfy-table = "6.0.0" cfg-if = "1.0.0" clap = { workspace = true, features = ["derive"] } +csv = "1.3.0" cw-utils = { workspace = true } handlebars = "3.0.1" humantime-serde = "1.0" +inquire = "0.6.2" k256 = { workspace = true, features = ["ecdsa", "sha256"] } log = { workspace = true } rand = {version = "0.6", features = ["std"] } diff --git a/common/commands/fixtures/test_send_multiple.csv b/common/commands/fixtures/test_send_multiple.csv new file mode 100644 index 0000000000..d291f15979 --- /dev/null +++ b/common/commands/fixtures/test_send_multiple.csv @@ -0,0 +1,4 @@ +n1q85lscptz860j3dx92f8phaeaw08j2l5dt7adq,50,nym +n136ckky0n39eskewg04xhvahq9yp9f7sgtnxytt,50,unym +n1xh7ru0zp67czxhvx0r5e8ur7rvg6n3ymmje0ju,50,unyx +n13mpm4aj03alffnvz2x9ynjy4u3cxemxn2xw34w,5000,nyx diff --git a/common/commands/src/validator/account/mod.rs b/common/commands/src/validator/account/mod.rs index b3cecc7c9a..422c478657 100644 --- a/common/commands/src/validator/account/mod.rs +++ b/common/commands/src/validator/account/mod.rs @@ -1,4 +1,4 @@ -// Copyright 2021 - Nym Technologies SA +// Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 use clap::{Args, Subcommand}; @@ -7,6 +7,7 @@ pub mod balance; pub mod create; pub mod pubkey; pub mod send; +pub mod send_multiple; #[derive(Debug, Args)] #[clap(args_conflicts_with_subcommands = true, subcommand_required = true)] @@ -25,4 +26,6 @@ pub enum AccountCommands { PubKey(crate::validator::account::pubkey::Args), /// Sends tokens to another account Send(crate::validator::account::send::Args), + /// Batch multiple token sends + SendMultiple(crate::validator::account::send_multiple::Args), } diff --git a/common/commands/src/validator/account/send_multiple.rs b/common/commands/src/validator/account/send_multiple.rs new file mode 100644 index 0000000000..56809e0fd6 --- /dev/null +++ b/common/commands/src/validator/account/send_multiple.rs @@ -0,0 +1,216 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::context::SigningClient; +use crate::utils::pretty_coin; +use clap::Parser; +use comfy_table::Table; +use cosmrs::rpc::endpoint::tx::Response; +use log::{error, info}; +use nym_validator_client::nyxd::{AccountId, Coin}; +use serde_json::json; +use std::str::FromStr; +use std::{fs, io::Write}; + +#[derive(Debug, Parser)] +pub struct Args { + #[clap(long)] + pub memo: Option, + + #[clap( + long, + help = "Input file path (CSV format) with account/amount pairs to send" + )] + pub input: String, + + #[clap( + long, + help = "An output file path (CSV format) to create or append a log of results to" + )] + pub output: Option, +} + +pub async fn send_multiple(args: Args, client: &SigningClient) { + let memo = args + .memo + .unwrap_or_else(|| "Sending tokens with nym-cli".to_owned()); + + let rows = InputFileReader::new(&args.input); + if let Err(e) = rows { + error!("Failed to read input file: {}", e); + return; + } + let rows = rows.unwrap(); + + let mut table = Table::new(); + + if rows.rows.is_empty() { + error!("No transactions to send"); + return; + } + + println!( + "The following transfer will be made from account {} to:", + client.address() + ); + table.set_header(vec!["Address", "Amount"]); + + for row in rows.rows.iter() { + table.add_row(vec![row.address.to_string(), pretty_coin(&row.amount)]); + } + + println!("{table}"); + + let ans = inquire::Confirm::new("Do you want to continue with the transfers?") + .with_default(false) + .with_help_message("You must confirm before the transaction will be sent") + .prompt(); + + if let Err(e) = ans { + info!("Aborting, {}...", e); + return; + } + if let Ok(false) = ans { + info!("Aborting!"); + return; + } + + info!("Transferring from {}...", client.address()); + + let multiple_sends: Vec<(AccountId, Vec)> = rows + .rows + .iter() + .map(|row| (row.address.clone(), vec![row.amount.clone()])) + .collect(); + + let res = client + .send_multiple(multiple_sends, memo, None) + .await + .expect("failed to send tokens!"); + + info!("Sending result: {}", json!(res)); + + println!(); + println!( + "Nodesguru: https://nym.explorers.guru/transaction/{}", + &res.hash + ); + println!("Mintscan: https://www.mintscan.io/nyx/txs/{}", &res.hash); + println!("Transaction result code: {}", &res.tx_result.code.value()); + println!("Transaction hash: {}", &res.hash); + + if let Some(output_filename) = args.output { + println!("\nWriting output log to {}", output_filename); + + if let Err(e) = write_output_file(rows, res, &output_filename) { + error!( + "Failed to write output file {} with error {}", + output_filename, e + ); + } + } +} + +fn write_output_file( + rows: InputFileReader, + res: Response, + output_filename: &String, +) -> Result<(), anyhow::Error> { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(output_filename)?; + + let now = time::OffsetDateTime::now_utc(); + let now = now.format(&time::format_description::well_known::Rfc3339)?; + + let data = rows + .rows + .iter() + .map(|row| { + format!( + "{},{},{},{},{}", + row.address, row.amount.amount, row.amount.denom, now, res.hash + ) + }) + .collect::>() + .join("\n"); + + Ok(file.write_all(format!("{}\n", data).as_bytes())?) +} + +#[derive(Debug)] +pub struct InputFileRow { + pub address: AccountId, + pub amount: Coin, +} + +pub struct InputFileReader { + pub rows: Vec, +} + +impl InputFileReader { + pub fn new(path: &str) -> Result { + let mut rows: Vec = vec![]; + let file_contents = fs::read_to_string(path)?; + + let lines: Vec = file_contents.lines().map(String::from).collect(); + for line in lines { + let tokens: Vec<_> = line.split(',').collect(); + if tokens.len() < 3 { + return Err(anyhow::anyhow!( + "'{}' does not have enough columns, expecting
,,", + line + )); + } + // try parse amount to u128 + let amount = u128::from_str(tokens[1]) + .map_err(|_| anyhow::anyhow!("'{}' has an invalid amount", line))?; + + let denom: String = tokens[2].into(); + + // multiply when a whole token amount, e.g. 50nym (50.123456nym is not allowed, that must be input as 50123456unym) + let (amount, denom) = if !denom.starts_with('u') { + (amount * 1_000_000u128, format!("u{}", denom)) + } else { + (amount, denom) + }; + + let address = AccountId::from_str(tokens[0]) + .map_err(|e| anyhow::anyhow!("'{}' has an invalid address: {}", line, e))?; + + let amount = Coin { amount, denom }; + + rows.push(InputFileRow { address, amount }) + } + + Ok(InputFileReader { rows }) + } +} + +#[cfg(test)] +mod test_multiple_send_input_csv { + use super::*; + use nym_validator_client::nyxd::AccountId; + use std::str::FromStr; + #[test] + fn works_on_happy_path() { + let input_csv = InputFileReader::new("fixtures/test_send_multiple.csv").unwrap(); + assert_eq!( + AccountId::from_str("n1q85lscptz860j3dx92f8phaeaw08j2l5dt7adq").unwrap(), + input_csv.rows[0].address + ); + + println!("{:?}", input_csv.rows); + + assert_eq!(50_000_000u128, input_csv.rows[0].amount.amount.into()); + assert_eq!(50u128, input_csv.rows[1].amount.amount.into()); + assert_eq!(50_000_000u128, input_csv.rows[2].amount.amount.into()); + assert_eq!(50u128, input_csv.rows[3].amount.amount.into()); + + assert_eq!("unym", input_csv.rows[0].amount.denom); + assert_eq!("unym", input_csv.rows[1].amount.denom); + assert_eq!("unyx", input_csv.rows[2].amount.denom); + assert_eq!("unyx", input_csv.rows[3].amount.denom); + } +} diff --git a/envs/qa.env b/envs/qa.env index 97d9838411..fa323cf41d 100644 --- a/envs/qa.env +++ b/envs/qa.env @@ -12,18 +12,18 @@ DENOMS_EXPONENT=6 MIXNET_CONTRACT_ADDRESS=n14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sjyvg3g VESTING_CONTRACT_ADDRESS=n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw -COCONUT_BANDWIDTH_CONTRACT_ADDRESS=n1w798gp0zqv3s9hjl3jlnwxtwhykga6rn93p46q2crsdqhaj3y4gs68f74j -GROUP_CONTRACT_ADDRESS=n1sthrn5ep8ls5vzz8f9gp89khhmedahhdqd244dh9uqzk3hx2pzrsvf7zgk -MULTISIG_CONTRACT_ADDRESS=n1sr06m8yqg0wzqqyqvzvp5t07dj4nevx9u8qc7j4qa72qu8e3ct8qledthy -COCONUT_DKG_CONTRACT_ADDRESS=n1udfs22xpxle475m2nz7u47jfa3vngncdegmczwwdx00cmetypa3s7uyuqn -EPHEMERA_CONTRACT_ADDRESS=n19lc9u84cz0yz3fww5283nucc9yvr8gsjmgeul0 +COCONUT_BANDWIDTH_CONTRACT_ADDRESS=n1aht6sekk0302hh30eje9a4lq84juwehw2hxv65kjh863ptay0sxs84mhx8 +GROUP_CONTRACT_ADDRESS=n17akgf5y2t775k27kulyw8durzsgfedym9wemg0qv3msc4ry0cg2senyl36 +MULTISIG_CONTRACT_ADDRESS=n1075wwvq8ypag74tmzeu7a05mplwsa95g2p8wnxhhctv7hlp6y3wq9ttqcg +COCONUT_DKG_CONTRACT_ADDRESS=n1hk7jy0m6ec5gventvgsx8skrz8mrudhqhun0ufg5gaa3mrr83qyq45v2zk REWARDING_VALIDATOR_ADDRESS=n1rfvpsynktze6wvn6ldskj8xgwfzzk5v6pnff39 NAME_SERVICE_CONTRACT_ADDRESS=n1qum2tr7hh4y7ruzew68c64myjec0dq2s2njf6waja5t0w879lutqadamme SERVICE_PROVIDER_DIRECTORY_CONTRACT_ADDRESS=n13ehuhysn5mqjeaheeuew2gjs785f6k7jm8vfsqg3jhtpkwppcmzq6m2hmz STATISTICS_SERVICE_DOMAIN_ADDRESS="https://mainnet-stats.nymte.ch:8090" EXPLORER_API=https://qa-network-explorer.qa.nymte.ch/api -NYXD="https://qa-validator.qa.nymte.ch/" +NYXD="https://qa-validator.qa.nymte.ch" NYM_API="https://qa-nym-api.qa.nymte.ch/api" DKG_TIME_CONFIGURATION="600,300,300,60,60,1209600" +EXIT_POLICY="https://nymtech.net/.wellknown/network-requester/exit-policy.txt" \ No newline at end of file diff --git a/tools/nym-cli/Cargo.toml b/tools/nym-cli/Cargo.toml index cd7edb07ef..29a6f85e2f 100644 --- a/tools/nym-cli/Cargo.toml +++ b/tools/nym-cli/Cargo.toml @@ -12,6 +12,7 @@ clap = { workspace = true, features = ["derive"] } clap_complete = "4.0" clap_complete_fig = "4.0" dotenvy = { workspace = true } +inquire = "0.6.2" log = { workspace = true } pretty_env_logger = "0.4" serde = { version = "1", features = ["derive"] } diff --git a/tools/nym-cli/src/validator/account.rs b/tools/nym-cli/src/validator/account.rs index b6c422170d..eb51786a4a 100644 --- a/tools/nym-cli/src/validator/account.rs +++ b/tools/nym-cli/src/validator/account.rs @@ -52,6 +52,13 @@ pub(crate) async fn execute( ) .await; } + Some(nym_cli_commands::validator::account::AccountCommands::SendMultiple(args)) => { + nym_cli_commands::validator::account::send_multiple::send_multiple( + args, + &create_signing_client(global_args, network_details)?, + ) + .await; + } _ => unreachable!(), }