diff --git a/common/commands/src/validator/mixnet/delegators/delegate_to_multiple_mixnodes.rs b/common/commands/src/validator/mixnet/delegators/delegate_to_multiple_mixnodes.rs new file mode 100644 index 0000000000..7be38f19ea --- /dev/null +++ b/common/commands/src/validator/mixnet/delegators/delegate_to_multiple_mixnodes.rs @@ -0,0 +1,313 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use std::cmp::Ordering; +use std::collections::HashMap; +use std::fs; +use std::fs::OpenOptions; + +use clap::Parser; +use comfy_table::Table; +use cosmrs::AccountId; +use csv::WriterBuilder; +use log::{info, warn}; + +use nym_mixnet_contract_common::PendingEpochEventKind::{Delegate, Undelegate}; +use nym_validator_client::nyxd::contract_traits::{MixnetSigningClient, PagedMixnetQueryClient}; +use nym_validator_client::nyxd::Coin; + +use crate::context::SigningClient; +use crate::utils::pretty_coin; + +#[derive(Debug, Parser)] +pub struct Args { + #[clap(long)] + pub memo: Option, + + #[clap(long, help = "Input csv files with delegation amounts")] + pub input: String, + + #[clap( + long, + help = "An output file path (CSV format) to create or append a log of results to", + + )] + pub output: Option, +} + +#[derive(Debug)] +pub struct InputFileRow { + pub mix_id: String, + pub amount: Coin, +} +#[derive(Debug)] +pub struct InputFileReader { + pub rows: Vec, +} + +impl InputFileReader { + pub fn new(path: &str) -> Result { + let mut rows: Vec = Vec::new(); + let file_contents = fs::read_to_string(path)?; + let mut mix_id_list: Vec = Vec::new(); + let lines: Vec = file_contents.lines().map(String::from).collect(); + for line in lines { + // Skip over blank lines without throwing an error + if line.trim().is_empty() { + continue; + } + + let tokens: Vec<_> = line.split(',').collect(); + // Return error if any of the line is malformed + if tokens.len() != 2 { + return Err(anyhow::anyhow!( + "Malformed input file, please make sure the file is in the correct format (mix_id,amount)" + )); + } + + let mix_id = tokens[0].trim().to_string(); + let token_input_raw = tokens[1].trim().parse::()?; + if token_input_raw > 1_000_000 { + warn!("Delegation amount exceeds 1,000,000. \ + Make sure the input amount is in nym and not unym denomination!"); + } + + let tokens_in_unym: u128 = token_input_raw * 1_000_000; + + let amount = Coin { + amount: tokens_in_unym, + denom: "unym".to_string(), + }; + if mix_id_list.contains(&mix_id) { + return Err(anyhow::anyhow!( + "Input document has duplicate delegation record for {}", + mix_id.clone() + )); + } else { + rows.push(InputFileRow { + mix_id: mix_id.clone(), + amount, + }); + mix_id_list.push(mix_id); + } + } + Ok(InputFileReader { rows }) + } +} + + +fn write_to_csv(output_details: Vec<[String; 4]>, output_file: Option) -> Result<(), anyhow::Error> { + if let Some(file_path) = output_file { + // Determine if the file exists and is not empty + let file_exists = fs::metadata(&file_path).map(|metadata| metadata.len() > 0).unwrap_or(false); + + // Open the file for appending or creation + let file = OpenOptions::new() + .write(true) + .append(true) + .create(true) + .open(&file_path)?; + + if !file_exists { + let mut wtr = csv::Writer::from_writer(&file); + wtr.write_record(["Operation", "Mix ID", "Amount", "tx hash"])?; + wtr.flush()?; + } + + let mut wtr = WriterBuilder::new().has_headers(!file_exists).from_writer(file); + + // Write the details to the CSV file + for detail in output_details { + wtr.write_record(&detail)?; + } + wtr.flush()?; + info!("All operations saved to output file"); + } + Ok(()) +} + +pub async fn delegate_to_multiple_mixnodes( + args: Args, + client: SigningClient, +) -> Result { + let address = client.address(); + let records = InputFileReader::new(&args.input)?; + // Fetch all delegations for the user + let delegations = client.get_all_delegator_delegations(&address).await?; + + // Build a map to make it easier to handle delegation data + let mut existing_delegation_map: HashMap = HashMap::new(); + let mut pending_delegation_map: HashMap = HashMap::new(); + + for delegation in delegations { + existing_delegation_map + .insert(delegation.mix_id.to_string(), Coin::from(delegation.amount)); + } + + // Look for pending delegate / undelegate events which might be of interest to us + let pending_events = client.get_all_pending_epoch_events().await?; + for event in pending_events { + match event.event.kind { + // If a pending undelegate tx is found, remove it from delegation map + Undelegate { owner, mix_id, .. } => { + if owner == client.address().as_ref() + && existing_delegation_map.get(&mix_id.to_string()).is_some() + { + existing_delegation_map.remove(&mix_id.to_string()); + } + } + + // If a pending delegation event is found, gather them to consolidate later + Delegate { + owner, + mix_id, + amount, + .. + } => { + if owner == client.address().as_ref() { + let mut amount = Coin::from(amount); + if let Some(pending_record) = pending_delegation_map.get(&mix_id.to_string()) { + amount.amount += pending_record.amount; + } + pending_delegation_map.insert(mix_id.to_string(), amount); + } + } + _ => {} + }; + } + + // Consolidate pending events into delegation map + for (mix_id, amount) in pending_delegation_map { + existing_delegation_map + .entry(mix_id) + .and_modify(|e| e.amount += amount.amount) + .or_insert(amount); + } + + let mut delegation_table = Table::new(); + let mut undelegation_table = Table::new(); + let mut delegations_to_be_made: Vec<(String, Coin)> = Vec::new(); + let mut undelegations_to_be_made = Vec::new(); + + delegation_table.set_header(["Mix ID", "Input Amount", "Adjusted Amount"]); + undelegation_table.set_header(["Mix ID"]); + + for row in records.rows.iter() { + // Check if there's an existing delegation for this mix_id + if let Some(existing_delegation_record) = existing_delegation_map.get(&row.mix_id) { + + let existing_delegation_amount = existing_delegation_record.amount; + let input_amount = row.amount.amount; + + match existing_delegation_amount.cmp(&input_amount) { + // Nothing to do if the delegation record matches, just continue + Ordering::Equal => continue, + + // If existing delegation is lesser, we need to delegate only remaining amount + Ordering::Less => { + let adjusted_amount = Coin { + amount: input_amount - existing_delegation_amount, + denom: existing_delegation_record.denom.clone(), + }; + + delegation_table.add_row([ + row.mix_id.clone(), + pretty_coin(&row.amount.clone()), + pretty_coin(&adjusted_amount), + ]); + delegations_to_be_made.push((row.mix_id.clone(), adjusted_amount)); + }, + + // If existing delegation is greater, we need to undelegate and delegate the specified amount + Ordering::Greater => { + undelegations_to_be_made.push(row.mix_id.clone()); + undelegation_table.add_row([row.mix_id.clone()]); + + delegations_to_be_made.push((row.mix_id.clone(), row.amount.clone())); + delegation_table.add_row([ + row.mix_id.clone(), + pretty_coin(&row.amount), + pretty_coin(&row.amount), + ]); + } + }; // match close + + } else { + delegations_to_be_made.push((row.mix_id.clone(), row.amount.clone())); + delegation_table.add_row([ + row.mix_id.clone(), + pretty_coin(&row.amount.clone()), + pretty_coin(&row.amount.clone()), + ]); + } + } + + if delegations_to_be_made.is_empty() && undelegations_to_be_made.is_empty() { + println!("Nothing to do. Delegations are up-to-date!"); + return Ok(address.clone()); + } + if !delegations_to_be_made.is_empty() { + println!("Delegation records : \n{}\n\n", delegation_table); + } + + if !undelegations_to_be_made.is_empty() { + println!("Undelegation records : \n{}\n\n", undelegation_table); + } + + let ans = inquire::Confirm::new("Do you want to continue with the shown operations?") + .with_default(false) + .with_help_message("You must confirm before the transactions are signed") + .prompt(); + + if let Err(e) = ans { + info!("Aborting, {}...", e); + return Err(anyhow::anyhow!("Aborted :: {}", e)); + } + + if let Ok(false) = ans { + info!("Aborting:: User denied proceeding with signing!"); + return Err(anyhow::anyhow!("Aborting:: User denied proceeding with signing!")); + } + + let mut output_details: Vec<[String;4]> = Vec::new(); + + // Perform undelegation operations + for mix_id in undelegations_to_be_made { + let res = client.undelegate_from_mixnode(mix_id.parse::()?, None).await.expect("Failed to undelegate from mixnode"); + info!("Delegation to {} successful. tx: {}", mix_id, &res.transaction_hash ); + if args.output.is_some() { + output_details.push([ + "Undelegate".into(), + mix_id.clone(), + "-".into(), + res.transaction_hash.to_string(), + ]); + } + } + + // Perform delegation operations + for (mix_id, amount) in delegations_to_be_made { + let res = client.delegate_to_mixnode(mix_id.parse::()?, amount.clone(), None) + .await + .expect("Failed to delegate to mixnode"); + + info!("Delegation to {} successful. tx: {}", mix_id, &res.transaction_hash ); + if args.output.is_some() { + output_details.push([ + "Delegate".into(), + mix_id.clone(), + pretty_coin(&amount), + res.transaction_hash.to_string(), + ]); + } + } + + if args.output.is_some() { + if let Err(e) = write_to_csv(output_details, args.output) { + info!("Failed to write to CSV, {}", e); + return Err(anyhow::anyhow!("Failed to write to CSV: {}", e)); + } + } + + Ok(address) +} diff --git a/common/commands/src/validator/mixnet/delegators/mod.rs b/common/commands/src/validator/mixnet/delegators/mod.rs index 61e87f119d..f2cf156c0c 100644 --- a/common/commands/src/validator/mixnet/delegators/mod.rs +++ b/common/commands/src/validator/mixnet/delegators/mod.rs @@ -6,6 +6,7 @@ use clap::{Args, Subcommand}; pub mod rewards; pub mod delegate_to_mixnode; +pub mod delegate_to_multiple_mixnodes; pub mod query_for_delegations; pub mod undelegate_from_mixnode; pub mod vesting_delegate_to_mixnode; @@ -26,6 +27,8 @@ pub enum MixnetDelegatorsCommands { Rewards(rewards::MixnetDelegatorsReward), /// Delegate to a mixnode Delegate(delegate_to_mixnode::Args), + /// Perform bulk delegations from an input file + DelegateMulti(delegate_to_multiple_mixnodes::Args), /// Undelegate from a mixnode Undelegate(undelegate_from_mixnode::Args), /// Delegate to a mixnode with locked tokens diff --git a/tools/nym-cli/src/validator/mixnet/delegators/mod.rs b/tools/nym-cli/src/validator/mixnet/delegators/mod.rs index b7c561cf70..69e83a5993 100644 --- a/tools/nym-cli/src/validator/mixnet/delegators/mod.rs +++ b/tools/nym-cli/src/validator/mixnet/delegators/mod.rs @@ -23,6 +23,9 @@ pub(crate) async fn execute( nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::DelegateVesting(args) => { nym_cli_commands::validator::mixnet::delegators::vesting_delegate_to_mixnode::vesting_delegate_to_mixnode(args, create_signing_client(global_args, network_details)?).await } + nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::DelegateMulti(args) => { + nym_cli_commands::validator::mixnet::delegators::delegate_to_multiple_mixnodes::delegate_to_multiple_mixnodes(args, create_signing_client(global_args, network_details)?).await.expect("TODO: panic message"); + } nym_cli_commands::validator::mixnet::delegators::MixnetDelegatorsCommands::Undelegate(args) => { nym_cli_commands::validator::mixnet::delegators::undelegate_from_mixnode::undelegate_from_mixnode(args, create_signing_client(global_args, network_details)?).await }