diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs index e74a56e122b..6f9d01eb6f3 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/reward_params.rs @@ -86,6 +86,25 @@ impl IntervalRewardParams { pub fn to_inline_json(&self) -> String { to_json_string(self).unwrap_or_else(|_| "serialisation failure".into()) } + + pub fn active_node_work(&self, standby_node_work: Decimal) -> WorkFactor { + self.active_set_work_factor * standby_node_work + } + + pub fn standby_node_work( + &self, + rewarded_set_size: Decimal, + standby_set_size: Decimal, + ) -> WorkFactor { + let f = self.active_set_work_factor; + let k = rewarded_set_size; + let one = Decimal::one(); + + // nodes in reserve + let k_r = standby_set_size; + + one / (f * k - (f - one) * k_r) + } } /// Parameters used for reward calculation. @@ -109,18 +128,15 @@ pub struct RewardingParams { impl RewardingParams { pub fn active_node_work(&self) -> WorkFactor { - self.interval.active_set_work_factor * self.standby_node_work() + let standby_work = self.standby_node_work(); + self.interval.active_node_work(standby_work) } pub fn standby_node_work(&self) -> WorkFactor { - let f = self.interval.active_set_work_factor; - let k = self.dec_rewarded_set_size(); - let one = Decimal::one(); - - // nodes in reserve - let k_r = self.dec_standby_set_size(); - - one / (f * k - (f - one) * k_r) + let rewarded_set_size = self.dec_rewarded_set_size(); + let standby_set_size = self.dec_standby_set_size(); + self.interval + .standby_node_work(rewarded_set_size, standby_set_size) } pub fn rewarded_set_size(&self) -> u32 { diff --git a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs index b3c4e5b2beb..9281e35bbbf 100644 --- a/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs +++ b/common/cosmwasm-smart-contracts/mixnet-contract/src/types.rs @@ -3,6 +3,7 @@ use crate::config_score::{ConfigScoreParams, OutdatedVersionWeights, VersionScoreFormulaParams}; use crate::nym_node::Role; +use crate::reward_params::RewardedSetParams; use crate::EpochId; use contracts_common::Percent; use cosmwasm_schema::cw_serde; @@ -85,7 +86,11 @@ impl RewardedSet { } pub fn rewarded_set_size(&self) -> usize { - self.active_set_size() + self.standby.len() + self.active_set_size() + self.standby_set_size() + } + + pub fn standby_set_size(&self) -> usize { + self.standby.len() } pub fn get_role(&self, node_id: NodeId) -> Option { @@ -110,6 +115,13 @@ impl RewardedSet { } None } + + pub fn matches_parameters(&self, params: RewardedSetParams) -> bool { + self.entry_gateways.len() <= params.entry_gateways as usize + && self.exit_gateways.len() <= params.exit_gateways as usize + && self.layer1.len() + self.layer2.len() + self.layer3.len() <= params.mixnodes as usize + && self.standby.len() <= params.standby as usize + } } #[cw_serde] diff --git a/nym-api/src/epoch_operations/helpers.rs b/nym-api/src/epoch_operations/helpers.rs index 098afd44e48..5f1e5a2f6b4 100644 --- a/nym-api/src/epoch_operations/helpers.rs +++ b/nym-api/src/epoch_operations/helpers.rs @@ -5,12 +5,16 @@ use crate::epoch_operations::EpochAdvancer; use crate::support::caching::Cache; use cosmwasm_std::{Decimal, Fraction}; use nym_api_requests::models::NodeAnnotation; +use nym_mixnet_contract_common::helpers::IntoBaseDecimal; use nym_mixnet_contract_common::reward_params::{NodeRewardingParameters, Performance, WorkFactor}; -use nym_mixnet_contract_common::{EpochRewardedSet, ExecuteMsg, NodeId, RewardingParams}; +use nym_mixnet_contract_common::{ + EpochRewardedSet, ExecuteMsg, NodeId, RewardedSet, RewardingParams, +}; use serde::{Deserialize, Serialize}; +use std::cmp::max; use std::collections::HashMap; use tokio::sync::RwLockReadGuard; -use tracing::error; +use tracing::{debug, error}; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub(crate) struct NodeWithPerformance { @@ -73,6 +77,137 @@ pub(super) fn stake_to_f64(stake: Decimal) -> f64 { } } +struct PerNodeWork { + active: WorkFactor, + standby: WorkFactor, +} + +struct NodeWorkCalculationComponents { + active_set_size: Decimal, + standby_set_size: Decimal, + per_node_work: PerNodeWork, +} + +impl NodeWorkCalculationComponents { + fn standby_set_work_share(&self) -> Decimal { + self.standby_set_size * self.per_node_work.standby + } + + fn active_set_work_share(&self) -> Decimal { + self.active_set_size * self.per_node_work.active + } +} + +fn default_node_work_calculation( + nodes: &RewardedSet, + global_rewarding_params: RewardingParams, +) -> NodeWorkCalculationComponents { + let per_node_work = PerNodeWork { + active: global_rewarding_params.active_node_work(), + standby: global_rewarding_params.standby_node_work(), + }; + // SANITY CHECK: + // SAFETY: 0 decimal places is within the range of `Decimal` + #[allow(clippy::unwrap_used)] + let standby_set_size = Decimal::from_atomics(nodes.standby.len() as u128, 0).unwrap(); + #[allow(clippy::unwrap_used)] + let active_set_size = Decimal::from_atomics(nodes.active_set_size() as u128, 0).unwrap(); + + NodeWorkCalculationComponents { + active_set_size, + standby_set_size, + per_node_work, + } +} + +fn manual_node_work_calculation( + nodes: &RewardedSet, + global_rewarding_params: RewardingParams, +) -> NodeWorkCalculationComponents { + // calculate everything manually based on the actual rewarded set on hand + // but always attempt to minimise the node work, so take the maximum values + // of the set sizes between new and old parameters + // (more nodes = smaller per-node work as it has to be spread through more entries) + let rewarded_set_size = max( + global_rewarding_params.rewarded_set.rewarded_set_size(), + nodes.rewarded_set_size() as u32, + ); + let standby_set_size = max( + global_rewarding_params.rewarded_set.standby, + nodes.standby_set_size() as u32, + ); + // the unwraps here are fine as we're guaranteed an `u32` is going to fit in a Decimal with 0 decimal places + #[allow(clippy::unwrap_used)] + let rewarded_set_size_dec = rewarded_set_size.into_base_decimal().unwrap(); + #[allow(clippy::unwrap_used)] + let standby_set_size_dec = standby_set_size.into_base_decimal().unwrap(); + #[allow(clippy::unwrap_used)] + let active_set_size = rewarded_set_size + .saturating_sub(standby_set_size) + .into_base_decimal() + .unwrap(); + + let standby_node_work = global_rewarding_params + .interval + .standby_node_work(rewarded_set_size_dec, standby_set_size_dec); + let active_node_work = global_rewarding_params + .interval + .active_node_work(standby_node_work); + let per_node_work = PerNodeWork { + active: active_node_work, + standby: standby_node_work, + }; + + NodeWorkCalculationComponents { + active_set_size, + standby_set_size: standby_set_size_dec, + per_node_work, + } +} + +fn determine_per_node_work( + nodes: &RewardedSet, + // we only need reward parameters for active set work factor and rewarded/active set sizes; + // we do not need exact values of reward pool, staking supply, etc., so it's fine if it's slightly out of sync + global_rewarding_params: RewardingParams, +) -> PerNodeWork { + // currently we are using constant omega for nodes, but that will change with tickets + // or different reward split between entry, exit, etc. at that point this will have to be calculated elsewhere + let res = if nodes.matches_parameters(global_rewarding_params.rewarded_set) { + default_node_work_calculation(nodes, global_rewarding_params) + } else { + error!("the current rewarded set does not much current rewarding parameters. this could only be expected if rewarded set distribution has been changed mid-epoch"); + manual_node_work_calculation(nodes, global_rewarding_params) + }; + + let active_node_work_factor = res.per_node_work.active; + let standby_node_work_factor = res.per_node_work.standby; + + debug!("using {active_node_work_factor} as active node work factor and {standby_node_work_factor} as standby node work factor"); + + let standby_share = res.standby_set_work_share(); + let active_share = res.active_set_work_share(); + let total_work = standby_share + active_share; + + // this HAS TO blow up. there's no recovery + #[allow(clippy::panic)] + if total_work > Decimal::one() { + panic!("work calculation logic is flawed! somehow the total work in the system is greater than 1! \ + total work={total_work}, \ + active set share={active_share}, \ + standby share={standby_share}, \ + active node work factor={active_node_work_factor}, \ + standby node work factor={standby_node_work_factor}, \ + active set size={} \ + standby set size={}", res.active_set_size, res.standby_set_size); + } + + PerNodeWork { + active: active_node_work_factor, + standby: standby_node_work_factor, + } +} + impl EpochAdvancer { fn load_performance( status_cache: &Option>>>, @@ -99,23 +234,9 @@ impl EpochAdvancer { global_rewarding_params: RewardingParams, ) -> Vec { let nodes = &nodes.assignment; - // currently we are using constant omega for nodes, but that will change with tickets - // or different reward split between entry, exit, etc. at that point this will have to be calculated elsewhere - let active_node_work_factor = global_rewarding_params.active_node_work(); - let standby_node_work_factor = global_rewarding_params.standby_node_work(); - - // SANITY CHECK: - // SAFETY: 0 decimal places is within the range of `Decimal` - #[allow(clippy::unwrap_used)] - let standby_share = Decimal::from_atomics(nodes.standby.len() as u128, 0).unwrap() - * standby_node_work_factor; - #[allow(clippy::unwrap_used)] - let active_share = Decimal::from_atomics(nodes.active_set_size() as u128, 0).unwrap() - * active_node_work_factor; - let total_work = standby_share + active_share; - - // this HAS TO blow up. there's no recovery - assert!(total_work <= Decimal::one(), "work calculation logic is flawed! somehow the total work in the system is greater than 1!"); + let nodes_work = determine_per_node_work(nodes, global_rewarding_params); + let active_node_work_factor = nodes_work.active; + let standby_node_work_factor = nodes_work.standby; let status_cache = self.status_cache.node_annotations().await; if status_cache.is_none() { @@ -161,6 +282,9 @@ impl EpochAdvancer { #[cfg(test)] mod tests { use super::*; + use nym_contracts_common::Percent; + use nym_mixnet_contract_common::reward_params::RewardedSetParams; + use nym_mixnet_contract_common::IntervalRewardParams; fn compare_large_floats(a: f64, b: f64) { // for very large floats, allow for smaller larger epsilon @@ -206,4 +330,72 @@ mod tests { compare_large_floats(expected_f64, stake_to_f64(decimal)) } } + + fn dummy_rewarding_params() -> RewardingParams { + RewardingParams { + interval: IntervalRewardParams { + reward_pool: Decimal::from_atomics(100_000_000_000_000u128, 0).unwrap(), + staking_supply: Decimal::from_atomics(123_456_000_000_000u128, 0).unwrap(), + staking_supply_scale_factor: Percent::hundred(), + epoch_reward_budget: Decimal::from_ratio(100_000_000_000_000u128, 1234u32) + * Decimal::percent(1), + stake_saturation_point: Decimal::from_ratio(123_456_000_000_000u128, 313u32), + sybil_resistance: Percent::from_percentage_value(23).unwrap(), + active_set_work_factor: Decimal::from_atomics(10u32, 0).unwrap(), + interval_pool_emission: Percent::from_percentage_value(1).unwrap(), + }, + rewarded_set: RewardedSetParams { + entry_gateways: 50, + exit_gateways: 70, + mixnodes: 120, + standby: 20, + }, + } + } + + #[test] + fn determining_nodes_work() { + let params = dummy_rewarding_params(); + // matched parameters + let rewarded_set = RewardedSet { + entry_gateways: (1..) + .take(params.rewarded_set.entry_gateways as usize) + .collect(), + exit_gateways: (1000..) + .take(params.rewarded_set.exit_gateways as usize) + .collect(), + layer1: (2000..) + .take(params.rewarded_set.mixnodes as usize / 3) + .collect(), + layer2: (3000..) + .take(params.rewarded_set.mixnodes as usize / 3) + .collect(), + layer3: (4000..) + .take(params.rewarded_set.mixnodes as usize / 3) + .collect(), + standby: (5000..) + .take(params.rewarded_set.standby as usize) + .collect(), + }; + + let work = determine_per_node_work(&rewarded_set, params); + assert_eq!(work.active, params.active_node_work()); + assert_eq!(work.standby, params.standby_node_work()); + + // updated + // here we're interested in the fact that the calculation does not panic, i.e. total work <= 1 + let params = dummy_rewarding_params(); + let rewarded_set = RewardedSet { + entry_gateways: (1..).take(250).collect(), + exit_gateways: (1000..).take(100).collect(), + layer1: (2000..).take(10).collect(), + layer2: (3000..).take(10).collect(), + layer3: (4000..).take(10).collect(), + standby: (5000..).take(5).collect(), + }; + + let work = determine_per_node_work(&rewarded_set, params); + assert_ne!(work.active, params.active_node_work()); + assert_ne!(work.standby, params.standby_node_work()); + } }