Compare commits

...

1 Commits

Author SHA1 Message Date
Jędrzej Stuczyński e32df11622 bugfix: manually calculate per node work on rewarded set changes (#5972) 2025-08-27 12:34:41 +01:00
3 changed files with 249 additions and 29 deletions
@@ -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 {
@@ -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<Role> {
@@ -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]
+211 -19
View File
@@ -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<RwLockReadGuard<Cache<HashMap<NodeId, NodeAnnotation>>>>,
@@ -99,23 +234,9 @@ impl EpochAdvancer {
global_rewarding_params: RewardingParams,
) -> Vec<RewardedNodeWithParams> {
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());
}
}