diff --git a/Cargo.lock b/Cargo.lock index d32390d53..2e3b6c3be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2090,6 +2090,7 @@ dependencies = [ "keyring", "log", "mockall", + "node-provider-rewards-api", "pretty_env_logger", "prost", "regex", @@ -3221,6 +3222,7 @@ dependencies = [ "ic-utils 0.39.3", "icrc-ledger-types", "log", + "node-provider-rewards-api", "prost", "reqwest", "serde", @@ -6291,6 +6293,7 @@ dependencies = [ "rust_decimal", "rust_decimal_macros", "serde", + "tabled", ] [[package]] diff --git a/rs/cli/Cargo.toml b/rs/cli/Cargo.toml index e5d4ebf2b..57f88c938 100644 --- a/rs/cli/Cargo.toml +++ b/rs/cli/Cargo.toml @@ -63,6 +63,7 @@ itertools = { workspace = true } keyring = { workspace = true, optional = true } log = { workspace = true } mockall.workspace = true +node-provider-rewards-api = { workspace = true } pretty_env_logger = { workspace = true } prost = { workspace = true } regex = { workspace = true } diff --git a/rs/cli/src/commands/main_command.rs b/rs/cli/src/commands/main_command.rs index 18caf96fe..25794f4bf 100644 --- a/rs/cli/src/commands/main_command.rs +++ b/rs/cli/src/commands/main_command.rs @@ -7,6 +7,7 @@ use super::hostos::HostOs; use super::network::Network; use super::neuron::Neuron; use super::node_metrics::NodeMetrics; +use super::node_provider_rewards::NodeProviderRewards; use super::nodes::Nodes; use super::proposals::Proposals; use super::propose::Propose; @@ -33,7 +34,7 @@ pub struct MainCommand { pub subcommands: Subcommands, } -impl_executable_command_for_enums! { MainCommand, DerToPrincipal, Network, Subnet, Get, Propose, UpdateUnassignedNodes, Version, NodeMetrics, HostOs, Nodes, ApiBoundaryNodes, Vote, Registry, Firewall, Upgrade, Proposals, Completions, Qualify, UpdateDefaultSubnets, Neuron, Governance } +impl_executable_command_for_enums! { MainCommand, DerToPrincipal, Network, Subnet, Get, Propose, UpdateUnassignedNodes, Version, NodeMetrics, NodeProviderRewards, HostOs, Nodes, ApiBoundaryNodes, Vote, Registry, Firewall, Upgrade, Proposals, Completions, Qualify, UpdateDefaultSubnets, Neuron, Governance } #[derive(Args, Debug)] pub struct Completions { diff --git a/rs/cli/src/commands/mod.rs b/rs/cli/src/commands/mod.rs index 7e5daa12a..9612c0c9c 100644 --- a/rs/cli/src/commands/mod.rs +++ b/rs/cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod main_command; pub(crate) mod network; pub(crate) mod neuron; pub(crate) mod node_metrics; +pub(crate) mod node_provider_rewards; pub(crate) mod nodes; pub(crate) mod proposals; pub(crate) mod propose; diff --git a/rs/cli/src/commands/node_provider_rewards.rs b/rs/cli/src/commands/node_provider_rewards.rs new file mode 100644 index 000000000..fde1c7190 --- /dev/null +++ b/rs/cli/src/commands/node_provider_rewards.rs @@ -0,0 +1,122 @@ +use crate::{auth::AuthRequirement, exe::ExecutableCommand}; +use clap::Args; +use ic_base_types::NodeId; +use ic_canisters::node_provider_rewards::NodeProviderRewardsCanisterWrapper; +use ic_types::PrincipalId; +use indexmap::IndexMap; +use node_provider_rewards_api::endpoints::{DailyResults, DayUTC, NodeProviderRewardsCalculationArgs, NodeStatus, RewardPeriodArgs, XDRPermyriad}; +use tabled::builder::Builder; +use tabled::settings::object::Rows; +use tabled::settings::style::LineText; + +#[derive(Args, Debug)] +pub struct NodeProviderRewards { + #[clap(long)] + pub provider_id: PrincipalId, + + pub start_date: String, + + pub end_date: String, +} + +impl ExecutableCommand for NodeProviderRewards { + fn require_auth(&self) -> AuthRequirement { + AuthRequirement::Anonymous + } + + fn validate(&self, _args: &crate::exe::args::GlobalArgs, _cmd: &mut clap::Command) {} + + async fn execute(&self, ctx: crate::ctx::DreContext) -> anyhow::Result<()> { + let (_, canister_agent) = ctx.create_ic_agent_canister_client().await?; + let args = NodeProviderRewardsCalculationArgs { + provider_id: self.provider_id, + reward_period: RewardPeriodArgs::from_dd_mm_yyyy(&self.start_date, &self.end_date)?, + }; + let npr_canister = NodeProviderRewardsCanisterWrapper::new(canister_agent); + let result = npr_canister.get_node_provider_rewards_calculation_v1(args).await?; + let mut overall_performance: IndexMap, XDRPermyriad)> = IndexMap::new(); + + for (node_id, node_results) in result.results_by_node { + let mut builder = Builder::default(); + + builder.push_record([ + "Day UTC", + "Status", + "Subnet FR", + "Blocks Proposed/Failed", + "Original FR", + "FR relative/extrapolated", + "Performance Multiplier", + "Base Rewards", + "Adjusted Rewards", + ]); + + for (day, results_by_day) in node_results.daily_results { + let mut record: Vec = vec![day.clone().into()]; + let DailyResults { + node_status, + performance_multiplier, + base_rewards, + adjusted_rewards, + .. + } = results_by_day; + let (underperforming_nodes, rewards_day_total) = overall_performance.entry(day).or_insert((Vec::new(), XDRPermyriad(0.0))); + + match node_status { + NodeStatus::Assigned { node_metrics } => { + let subnet_prefix = node_metrics.subnet_assigned.get().to_string().split('-').next().unwrap().to_string(); + record.extend(vec![ + format!("{} - {}", "Assigned", subnet_prefix), + node_metrics.subnet_assigned_fr.to_string(), + format!("{}/{}", node_metrics.num_blocks_proposed, node_metrics.num_blocks_failed), + node_metrics.original_fr.to_string(), + node_metrics.relative_fr.to_string(), + ]) + } + NodeStatus::Unassigned { extrapolated_fr } => record.extend(vec![ + "Unassigned".to_string(), + "N/A".to_string(), + "N/A".to_string(), + "N/A".to_string(), + extrapolated_fr.to_string(), + ]), + }; + + if performance_multiplier.0 < 1.0 { + underperforming_nodes.push(node_id); + } + rewards_day_total.0 += adjusted_rewards.0; + + record.extend(vec![ + performance_multiplier.to_string(), + base_rewards.to_string(), + adjusted_rewards.to_string(), + ]); + builder.push_record(record); + } + + let mut table = builder.build(); + let node_title = format!("Node ID: {}", node_id.get().to_string()); + table.with(LineText::new(node_title, Rows::first()).offset(2)); + println!("{}", table); + } + + let mut builder = Builder::default(); + builder.push_record(["Day UTC", "Underperforming Nodes", "Total Daily Rewards"]); + for (day, (underperforming_nodes, total_rewards)) in overall_performance { + let node_ids: Vec = underperforming_nodes + .iter() + .map(|id| id.get().to_string().split('-').next().unwrap().to_string()) + .collect(); + builder.push_record([day.into(), node_ids.join("\n"), total_rewards.to_string()]); + } + let mut table = builder.build(); + let title = format!( + "Overall Performance for Provider: {} from {} to {}", + self.provider_id, self.start_date, self.end_date + ); + table.with(LineText::new(title, Rows::first()).offset(2)); + println!("{}", table); + Ok(()) + } +} diff --git a/rs/dre-canisters/node-provider-rewards/canister/api/Cargo.toml b/rs/dre-canisters/node-provider-rewards/canister/api/Cargo.toml index 5853b2696..56bb2f1fa 100644 --- a/rs/dre-canisters/node-provider-rewards/canister/api/Cargo.toml +++ b/rs/dre-canisters/node-provider-rewards/canister/api/Cargo.toml @@ -13,6 +13,7 @@ rust_decimal = { workspace = true } rust_decimal_macros = { workspace = true } rewards-calculation = { workspace = true } candid = { workspace = true } +tabled = { workspace = true } serde = { workspace = true } itertools = { workspace = true } diff --git a/rs/dre-canisters/node-provider-rewards/canister/api/src/endpoints.rs b/rs/dre-canisters/node-provider-rewards/canister/api/src/endpoints.rs index 23fdeb51c..b3fbe3e33 100644 --- a/rs/dre-canisters/node-provider-rewards/canister/api/src/endpoints.rs +++ b/rs/dre-canisters/node-provider-rewards/canister/api/src/endpoints.rs @@ -1,9 +1,10 @@ -use chrono::DateTime; +use chrono::{DateTime, Datelike, NaiveDate, ParseError, TimeZone, Utc}; use ic_base_types::{NodeId, PrincipalId, SubnetId}; use rewards_calculation::rewards_calculator_results; use rust_decimal::prelude::ToPrimitive; use rust_decimal::Decimal; use std::collections::BTreeMap; +use std::fmt::Display; // FIXME: these fields need to be documented! Are they inclusive or exclusive ranges? How does this work? #[derive(candid::CandidType, candid::Deserialize, Clone)] @@ -17,6 +18,29 @@ pub struct RewardPeriodArgs { pub end_ts: u64, } +impl RewardPeriodArgs { + /// Parses two dates in "dd-mm-yyyy" format and returns RewardPeriodArgs + pub fn from_dd_mm_yyyy(start: &str, end: &str) -> Result { + // Parse input dates + let start_date = NaiveDate::parse_from_str(start, "%d-%m-%Y")?; + let end_date = NaiveDate::parse_from_str(end, "%d-%m-%Y")?; + + let start_dt = Utc + .with_ymd_and_hms(start_date.year(), start_date.month(), start_date.day(), 0, 0, 0) + .single() + .unwrap_or_default(); + let end_dt = Utc + .with_ymd_and_hms(end_date.year(), end_date.month(), end_date.day(), 23, 59, 59) + .single() + .unwrap_or_default(); + + Ok(RewardPeriodArgs { + start_ts: start_dt.timestamp_nanos_opt().unwrap() as u64, + end_ts: end_dt.timestamp_nanos_opt().unwrap() as u64, + }) + } +} + #[derive(candid::CandidType, candid::Deserialize)] pub struct NodeProviderRewardsCalculationArgs { pub provider_id: PrincipalId, @@ -28,7 +52,7 @@ fn decimal_to_f64(value: Decimal) -> Result { } #[derive(candid::CandidType, candid::Deserialize)] -pub struct XDRPermyriad(f64); +pub struct XDRPermyriad(pub f64); impl TryFrom for XDRPermyriad { type Error = String; @@ -37,8 +61,14 @@ impl TryFrom for XDRPermyriad { } } -#[derive(candid::CandidType, candid::Deserialize)] -pub struct Percent(f64); +impl Display for XDRPermyriad { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.4} XDR", self.0 / 10_000.0) + } +} + +#[derive(candid::CandidType, candid::Deserialize, Debug)] +pub struct Percent(pub f64); impl TryFrom for Percent { type Error = String; @@ -47,7 +77,13 @@ impl TryFrom for Percent { } } -#[derive(candid::CandidType, candid::Deserialize, Ord, PartialOrd, Eq, PartialEq)] +impl Display for Percent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.2}%", self.0 * 100.0) + } +} + +#[derive(candid::CandidType, candid::Deserialize, Ord, PartialOrd, Eq, PartialEq, Clone, Hash)] pub struct DayUTC(String); impl From for DayUTC { fn from(value: rewards_calculator_results::DayUTC) -> Self { @@ -60,6 +96,12 @@ impl From for DayUTC { } } +impl From for String { + fn from(value: DayUTC) -> Self { + value.0 + } +} + #[derive(candid::CandidType, candid::Deserialize)] pub struct NodeProvidersRewards { pub rewards_per_provider: BTreeMap, @@ -188,7 +230,6 @@ pub enum NodeStatus { Assigned { node_metrics: NodeMetricsDaily }, Unassigned { extrapolated_fr: Percent }, } - #[derive(candid::CandidType, candid::Deserialize)] pub struct DailyResults { pub node_status: NodeStatus, @@ -203,7 +244,7 @@ pub struct NodeResultsV1 { pub node_type: String, pub region: String, pub dc_id: String, - pub daily_results: BTreeMap, + pub daily_results: Vec<(DayUTC, DailyResults)>, } #[derive(candid::CandidType, candid::Deserialize)] @@ -223,7 +264,7 @@ impl TryFrom for RewardsCa let region = node_results.region.0.clone(); let node_type = node_results.node_reward_type.as_str_name().to_string(); let dc_id = node_results.dc_id.to_string(); - let daily_results: BTreeMap = node_results + let daily_results: Vec<(DayUTC, DailyResults)> = node_results .rewardable_days .into_iter() .map(|day| { @@ -273,7 +314,7 @@ impl TryFrom for RewardsCa }, )) }) - .collect::, String>>()?; + .collect::, String>>()?; Ok(( node_id, diff --git a/rs/ic-canisters/Cargo.toml b/rs/ic-canisters/Cargo.toml index 1a879dcac..690c5c6ff 100644 --- a/rs/ic-canisters/Cargo.toml +++ b/rs/ic-canisters/Cargo.toml @@ -40,3 +40,4 @@ ic-types.workspace = true ic-interfaces-registry.workspace = true ic-registry-nns-data-provider.workspace = true icrc-ledger-types.workspace = true +node-provider-rewards-api = { workspace = true } diff --git a/rs/ic-canisters/src/lib.rs b/rs/ic-canisters/src/lib.rs index 488aa5911..b7b5f2c5f 100644 --- a/rs/ic-canisters/src/lib.rs +++ b/rs/ic-canisters/src/lib.rs @@ -21,6 +21,7 @@ pub mod governance; pub mod ledger; pub mod management; pub mod node_metrics; +pub mod node_provider_rewards; pub mod parallel_hardware_identity; pub mod registry; pub mod sns_wasm; diff --git a/rs/ic-canisters/src/node_provider_rewards.rs b/rs/ic-canisters/src/node_provider_rewards.rs new file mode 100644 index 000000000..9e820db67 --- /dev/null +++ b/rs/ic-canisters/src/node_provider_rewards.rs @@ -0,0 +1,40 @@ +use candid::Principal; +use log::error; +use node_provider_rewards_api::endpoints::{NodeProviderRewardsCalculationArgs, RewardsCalculatorResultsV1}; +use std::str::FromStr; + +use crate::IcAgentCanisterClient; +const NODE_PROVIDER_REWARDS_CANISTER: &str = "4ofd5-6aaaa-aaaaa-qahza-cai"; + +pub struct NodeProviderRewardsCanisterWrapper { + agent: IcAgentCanisterClient, +} + +impl From for NodeProviderRewardsCanisterWrapper { + fn from(value: IcAgentCanisterClient) -> Self { + NodeProviderRewardsCanisterWrapper::new(value) + } +} + +impl NodeProviderRewardsCanisterWrapper { + pub fn new(agent: IcAgentCanisterClient) -> Self { + Self { agent } + } + + pub async fn get_node_provider_rewards_calculation_v1( + &self, + args: NodeProviderRewardsCalculationArgs, + ) -> anyhow::Result { + self.agent + .query::>( + &Principal::from_str(NODE_PROVIDER_REWARDS_CANISTER).map_err(anyhow::Error::from)?, + "get_node_provider_rewards_calculation_v1", + candid::encode_one(args)?, + ) + .await? + .map_err(|e| { + error!("Failed to decode RewardsCalculatorResultsV1"); + anyhow::anyhow!(e) + }) + } +}