diff --git a/rs/nns/integration_tests/src/node_provider_remuneration.rs b/rs/nns/integration_tests/src/node_provider_remuneration.rs index 1051bf7cb7a9..726a77223e17 100644 --- a/rs/nns/integration_tests/src/node_provider_remuneration.rs +++ b/rs/nns/integration_tests/src/node_provider_remuneration.rs @@ -506,6 +506,7 @@ fn test_automated_node_provider_remuneration() { NodeRewardType::Type1.to_string() => 2, NodeRewardType::Type3.to_string() => 2, NodeRewardType::Type3dot1.to_string() => 3, + NodeRewardType::Type4.to_string() => 2, }; add_node_operator( &state_machine, @@ -560,6 +561,18 @@ fn test_automated_node_provider_remuneration() { 15, NodeRewardType::Type3dot1, ), + add_node( + &state_machine, + node_info_3.operator_id, + 16, + NodeRewardType::Type4, + ), + add_node( + &state_machine, + node_info_3.operator_id, + 17, + NodeRewardType::Type4, + ), ]; nodes .entry(node_info_3.provider_id) @@ -685,7 +698,7 @@ fn test_automated_node_provider_remuneration() { // Rewards Table: // EU: Type 1: 24,000 XDR/month, Type 3: 35,000 XDR/month // North America, Canada: Type 1: 68,000 XDR/month, Type 3: 11,000 XDR/month - // North America, US, CA: Type 1: 234,000 XDR/month, Type 3: 907,000 XDR/month, Type 3.1: 103,000 XDR/month + // North America, US, CA: Type 1: 234,000 XDR/month, Type 3: 907,000 XDR/month, Type 3.1: 103,000 XDR/month, Type 4: 150,000 XDR/month // This node provider owns more than 1 Type3* node // Average reward rate will be applied for Type3* nodes @@ -699,11 +712,20 @@ fn test_automated_node_provider_remuneration() { + 0.8 * 0.8 * 0.8 * 0.8 * 103_000.0) / 5.0; + // Type4 rewards: 2 nodes * 150,000 XDR/month (flat, no reduction) + let type4_rewards = 2.0 * 150_000.0; + let expected_daily_rewards_xdrp_3 = - (((2.0 * 234_000.0) + (5.0 * average_type3_reduced_rewards)) / REWARDS_TABLE_DAYS) as u64; + (((2.0 * 234_000.0) + (5.0 * average_type3_reduced_rewards) + type4_rewards) + / REWARDS_TABLE_DAYS) as u64; let expected_rewards_e8s_3 = expected_daily_rewards_xdrp_3 * TOKEN_SUBDIVIDABLE_BY * expected_reward_days_covered_1 / 155_000; + if expected_reward_days_covered_1 == 30 { + assert_eq!(expected_rewards_e8s_3, 1399870967); + } else { + assert_eq!(expected_rewards_e8s_3, 1446500000); + } let expected_node_provider_reward_3 = RewardNodeProvider { node_provider: Some(node_info_3.provider.clone()), amount_e8s: expected_rewards_e8s_3, @@ -1325,6 +1347,10 @@ fn add_node_rewards_table(state_machine: &StateMachine) { xdr_permyriad_per_node_per_month: 103_000, reward_coefficient_percent: None, }, + "type4".to_string() => NodeRewardRate { + xdr_permyriad_per_node_per_month: 150_000, + reward_coefficient_percent: None, + }, } } }; diff --git a/rs/node_rewards/node_provider_reward_calculations.md b/rs/node_rewards/node_provider_reward_calculations.md index a7a4bd014fab..6e41154bd41c 100644 --- a/rs/node_rewards/node_provider_reward_calculations.md +++ b/rs/node_rewards/node_provider_reward_calculations.md @@ -146,6 +146,8 @@ Types: - type1.1: Gen1 nodes with increased storage capacity and reduced rewards after the initial 48 month agreements - type2: Not in use anymore - type3: Currently used node types with decreasing reward scale +- type3.1: Same as type3 but with different values in the Node Rewards Table +- type4: Cloud Engine nodes Reward calculation varies by: diff --git a/rs/node_rewards/rewards_calculation/src/performance_based_algorithm/v1/e2e_tests.rs b/rs/node_rewards/rewards_calculation/src/performance_based_algorithm/v1/e2e_tests.rs index d59e5969d963..e38d1cdb1b5d 100644 --- a/rs/node_rewards/rewards_calculation/src/performance_based_algorithm/v1/e2e_tests.rs +++ b/rs/node_rewards/rewards_calculation/src/performance_based_algorithm/v1/e2e_tests.rs @@ -828,3 +828,299 @@ fn test_zero_blocks_edge_cases() { assert_eq!(node2.performance_multiplier, dec!(0.2)); assert_eq!(node2.adjusted_rewards_xdr_permyriad, dec!(8000)); // Max penalty 40000 * 0.2 = 8000 } + +// ------------------------------------------------------------------------------------------------ +// Type4 Reward Calculation Tests +// ------------------------------------------------------------------------------------------------ + +/// **Scenario**: Type4 node in a region NOT present in the rewards table +/// **Expected**: Falls back to default rewards (1 permyriad, coefficient 1.0) +/// **Key Test**: Verifies that missing type4 entries in rewards table result in minimal rewards +#[test] +fn test_type4_not_in_rewards_table() { + let day = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let provider_id = test_provider_id(1); + let subnet_id = test_subnet_id(1); + + // Use standard rewards table which does NOT have type4 entries + let fake_input_provider = FakeInputProvider::new() + .add_rewards_table(day, FakeInputProvider::create_rewards_table()) + .add_daily_metrics( + day, + subnet_id, + vec![NodeMetricsDailyRaw { + node_id: test_node_id(1), + num_blocks_proposed: 100, + num_blocks_failed: 5, // 4.76% failure rate + }], + ) + .add_rewardable_nodes( + day, + provider_id, + vec![RewardableNode { + node_id: test_node_id(1), + node_reward_type: NodeRewardType::Type4, + region: "Europe,Germany,Berlin".into(), // Region with no type4 entry + dc_id: "dc1".into(), + }], + ); + + let result = RewardsCalculationV1::calculate_rewards(day, day, fake_input_provider) + .expect("Calculation should succeed"); + + let daily_result = &result.daily_results[&day]; + let provider_result = &daily_result.provider_results[&provider_id]; + + // Type4 should NOT use type3 logic (no grouping by country) + assert!(provider_result.type3_base_rewards.is_empty()); + + // Verify base rewards entry exists for type4 + let type4_base_rewards = provider_result + .base_rewards + .iter() + .find(|r| r.node_reward_type == NodeRewardType::Type4) + .expect("Should have base rewards for type4"); + + // When type is not in rewards table, it defaults to 1 permyriad monthly + // Daily = 1 / 30.4375 (REWARDS_TABLE_DAYS) ≈ 0.0328... + assert_eq!(type4_base_rewards.monthly_xdr_permyriad, dec!(1)); + + // Verify node rewards + let node_rewards = &provider_result.daily_nodes_rewards[0]; + assert_eq!(node_rewards.node_id, test_node_id(1)); + assert_eq!(node_rewards.node_reward_type, NodeRewardType::Type4); + + // Base rewards should be 1 / 30.4375 ≈ 0.0328... + assert_eq!( + node_rewards.base_rewards_xdr_permyriad, + dec!(0.0328542094455852156057494867) + ); + + // With good performance (relative FR = 0), performance_multiplier should be 1.0 + assert_eq!(node_rewards.performance_multiplier, dec!(1.0)); + assert_eq!(node_rewards.rewards_reduction, dec!(0.0)); + + // Adjusted rewards = base * performance_multiplier ≈ 0.0328... + assert_eq!( + node_rewards.adjusted_rewards_xdr_permyriad, + dec!(0.0328542094455852156057494867) + ); + + // Total rewards should be essentially zero (truncated to 0) + assert_eq!(provider_result.total_adjusted_rewards_xdr_permyriad, 0); +} + +/// **Scenario**: Type4 node in a region where type4 is explicitly set to 0 XDR +/// **Expected**: Node receives exactly 0 rewards +/// **Key Test**: Verifies that explicit 0 rate results in 0 rewards (different from fallback behavior) +#[test] +fn test_type4_explicit_zero_rate() { + let day = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let provider_id = test_provider_id(1); + let subnet_id = test_subnet_id(1); + + // Create a rewards table with type4 explicitly set to 0 + let mut rewards_table = FakeInputProvider::create_rewards_table(); + rewards_table.table.insert( + "North America,Canada,BC".to_string(), + NodeRewardRates { + rates: btreemap! { + NodeRewardType::Type4.to_string() => NodeRewardRate { + xdr_permyriad_per_node_per_month: 0, // Explicitly 0 + reward_coefficient_percent: None, + }, + }, + }, + ); + + let fake_input_provider = FakeInputProvider::new() + .add_rewards_table(day, rewards_table) + .add_daily_metrics( + day, + subnet_id, + vec![ + NodeMetricsDailyRaw { + node_id: test_node_id(1), + num_blocks_proposed: 100, + num_blocks_failed: 0, // Perfect performance + }, + NodeMetricsDailyRaw { + node_id: test_node_id(2), + num_blocks_proposed: 100, + num_blocks_failed: 0, // Perfect performance + }, + ], + ) + .add_rewardable_nodes( + day, + provider_id, + vec![ + RewardableNode { + node_id: test_node_id(1), + node_reward_type: NodeRewardType::Type4, + region: "North America,Canada,BC".into(), + dc_id: "dc1".into(), + }, + RewardableNode { + node_id: test_node_id(2), + node_reward_type: NodeRewardType::Type4, + region: "North America,Canada,BC".into(), + dc_id: "dc2".into(), + }, + ], + ); + + let result = RewardsCalculationV1::calculate_rewards(day, day, fake_input_provider) + .expect("Calculation should succeed"); + + let daily_result = &result.daily_results[&day]; + let provider_result = &daily_result.provider_results[&provider_id]; + + // Type4 should NOT use type3 logic + assert!(provider_result.type3_base_rewards.is_empty()); + + // Verify base rewards entry exists for type4 with 0 rate + let type4_base_rewards = provider_result + .base_rewards + .iter() + .find(|r| r.node_reward_type == NodeRewardType::Type4) + .expect("Should have base rewards for type4"); + + assert_eq!(type4_base_rewards.monthly_xdr_permyriad, dec!(0)); + assert_eq!(type4_base_rewards.daily_xdr_permyriad, dec!(0)); + + // Verify both nodes have 0 base rewards + let node_results: HashMap<_, _> = provider_result + .daily_nodes_rewards + .iter() + .map(|n| (n.node_id, n)) + .collect(); + + let node1 = node_results.get(&test_node_id(1)).unwrap(); + let node2 = node_results.get(&test_node_id(2)).unwrap(); + + // Both nodes should have exactly 0 rewards + assert_eq!(node1.base_rewards_xdr_permyriad, dec!(0)); + assert_eq!(node2.base_rewards_xdr_permyriad, dec!(0)); + assert_eq!(node1.adjusted_rewards_xdr_permyriad, dec!(0)); + assert_eq!(node2.adjusted_rewards_xdr_permyriad, dec!(0)); + + // Total rewards should be exactly 0 + assert_eq!(provider_result.total_adjusted_rewards_xdr_permyriad, 0); + assert_eq!(provider_result.total_base_rewards_xdr_permyriad, 0); +} + +/// **Scenario**: Type4 node in a region that IS present in the rewards table +/// **Expected**: Uses the configured rate from rewards table, no reduction coefficient logic +/// **Key Test**: Verifies type4 uses flat per-node rewards (not type3 grouped logic) +#[test] +fn test_type4_in_rewards_table() { + let day = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); + let provider_id = test_provider_id(1); + let subnet_id = test_subnet_id(1); + + // Create a rewards table with type4 entry + let mut rewards_table = FakeInputProvider::create_rewards_table(); + rewards_table.table.insert( + "Europe,Germany,Berlin".to_string(), + NodeRewardRates { + rates: btreemap! { + NodeRewardType::Type4.to_string() => NodeRewardRate { + xdr_permyriad_per_node_per_month: 608750, // 20000/day + reward_coefficient_percent: Some(85), // Should be ignored for type4 + }, + }, + }, + ); + + let fake_input_provider = FakeInputProvider::new() + .add_rewards_table(day, rewards_table) + .add_daily_metrics( + day, + subnet_id, + vec![ + NodeMetricsDailyRaw { + node_id: test_node_id(1), + num_blocks_proposed: 100, + num_blocks_failed: 5, // 4.76% failure rate + }, + NodeMetricsDailyRaw { + node_id: test_node_id(2), + num_blocks_proposed: 100, + num_blocks_failed: 10, // 9.09% failure rate + }, + ], + ) + .add_rewardable_nodes( + day, + provider_id, + vec![ + RewardableNode { + node_id: test_node_id(1), + node_reward_type: NodeRewardType::Type4, + region: "Europe,Germany,Berlin".into(), + dc_id: "dc1".into(), + }, + RewardableNode { + node_id: test_node_id(2), + node_reward_type: NodeRewardType::Type4, + region: "Europe,Germany,Berlin".into(), + dc_id: "dc2".into(), + }, + ], + ); + + let result = RewardsCalculationV1::calculate_rewards(day, day, fake_input_provider) + .expect("Calculation should succeed"); + + let daily_result = &result.daily_results[&day]; + let provider_result = &daily_result.provider_results[&provider_id]; + + // Type4 should NOT use type3 logic - no country-level grouping + assert!( + provider_result.type3_base_rewards.is_empty(), + "Type4 should not have type3 base rewards grouping" + ); + + // Verify base rewards entry exists for type4 + let type4_base_rewards = provider_result + .base_rewards + .iter() + .find(|r| r.node_reward_type == NodeRewardType::Type4) + .expect("Should have base rewards for type4"); + + assert_eq!(type4_base_rewards.monthly_xdr_permyriad, dec!(608750)); + assert_eq!(type4_base_rewards.daily_xdr_permyriad, dec!(20000)); // 608750 / 30.4375 + + // Verify both nodes have the same base rewards (no reduction coefficient applied) + let node_results: HashMap<_, _> = provider_result + .daily_nodes_rewards + .iter() + .map(|n| (n.node_id, n)) + .collect(); + + let node1 = node_results.get(&test_node_id(1)).unwrap(); + let node2 = node_results.get(&test_node_id(2)).unwrap(); + + // Both nodes should have the same base rewards (no grouping/reduction like type3) + assert_eq!(node1.base_rewards_xdr_permyriad, dec!(20000)); + assert_eq!(node2.base_rewards_xdr_permyriad, dec!(20000)); + + // Verify subnet failure rate (75th percentile of 4.76% and 9.09% = 9.09%) + let subnet_fr = daily_result.subnets_failure_rate[&subnet_id]; + assert_eq!(subnet_fr, dec!(0.0909090909090909090909090909)); + + // Node 1 (4.76% failure rate) - below subnet FR, no penalty + assert_eq!(node1.performance_multiplier, dec!(1.0)); + assert_eq!(node1.rewards_reduction, dec!(0.0)); + assert_eq!(node1.adjusted_rewards_xdr_permyriad, dec!(20000)); + + // Node 2 (9.09% failure rate = subnet FR) - relative FR = 0, no penalty + assert_eq!(node2.performance_multiplier, dec!(1.0)); + assert_eq!(node2.rewards_reduction, dec!(0.0)); + assert_eq!(node2.adjusted_rewards_xdr_permyriad, dec!(20000)); + + // Total rewards = 20000 + 20000 = 40000 + assert_eq!(provider_result.total_adjusted_rewards_xdr_permyriad, 40000); + assert_eq!(provider_result.total_base_rewards_xdr_permyriad, 40000); +} diff --git a/rs/protobuf/def/registry/node/v1/node.proto b/rs/protobuf/def/registry/node/v1/node.proto index b584f3c2f9f9..022a73141aef 100644 --- a/rs/protobuf/def/registry/node/v1/node.proto +++ b/rs/protobuf/def/registry/node/v1/node.proto @@ -37,6 +37,8 @@ enum NodeRewardType { NODE_REWARD_TYPE_TYPE3DOT1 = 5; // type1.1 NODE_REWARD_TYPE_TYPE1DOT1 = 6; + // type4 + NODE_REWARD_TYPE_TYPE4 = 7; } // A node: one machine running a replica instance. diff --git a/rs/protobuf/src/gen/registry/registry.node.v1.rs b/rs/protobuf/src/gen/registry/registry.node.v1.rs index c5da1b49ad9c..00aef3c5e072 100644 --- a/rs/protobuf/src/gen/registry/registry.node.v1.rs +++ b/rs/protobuf/src/gen/registry/registry.node.v1.rs @@ -97,6 +97,8 @@ pub enum NodeRewardType { Type3dot1 = 5, /// type1.1 Type1dot1 = 6, + /// type4 + Type4 = 7, } impl NodeRewardType { /// String value of the enum field names used in the ProtoBuf definition. @@ -112,6 +114,7 @@ impl NodeRewardType { Self::Type3 => "NODE_REWARD_TYPE_TYPE3", Self::Type3dot1 => "NODE_REWARD_TYPE_TYPE3DOT1", Self::Type1dot1 => "NODE_REWARD_TYPE_TYPE1DOT1", + Self::Type4 => "NODE_REWARD_TYPE_TYPE4", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -124,6 +127,7 @@ impl NodeRewardType { "NODE_REWARD_TYPE_TYPE3" => Some(Self::Type3), "NODE_REWARD_TYPE_TYPE3DOT1" => Some(Self::Type3dot1), "NODE_REWARD_TYPE_TYPE1DOT1" => Some(Self::Type1dot1), + "NODE_REWARD_TYPE_TYPE4" => Some(Self::Type4), _ => None, } } diff --git a/rs/protobuf/src/registry/node.rs b/rs/protobuf/src/registry/node.rs index c850826d1930..63efcd740728 100644 --- a/rs/protobuf/src/registry/node.rs +++ b/rs/protobuf/src/registry/node.rs @@ -16,6 +16,7 @@ impl From for String { NodeRewardType::Type2 => "type2".to_string(), NodeRewardType::Type3 => "type3".to_string(), NodeRewardType::Type3dot1 => "type3.1".to_string(), + NodeRewardType::Type4 => "type4".to_string(), } } } @@ -35,6 +36,7 @@ impl From for NodeRewardType { "type2" => NodeRewardType::Type2, "type3" => NodeRewardType::Type3, "type3.1" => NodeRewardType::Type3dot1, + "type4" => NodeRewardType::Type4, _ => NodeRewardType::Unspecified, } } diff --git a/rs/protobuf/src/registry/node_rewards.rs b/rs/protobuf/src/registry/node_rewards.rs index fe508b4ed0f5..ad41a9340f89 100644 --- a/rs/protobuf/src/registry/node_rewards.rs +++ b/rs/protobuf/src/registry/node_rewards.rs @@ -134,14 +134,14 @@ pub mod v2 { #[test] fn test_from_btreemap_parsing_reduction_coefficient() { let json = r#"{ - "North America,US": { "type0": [100, null], "type2": [200, null], "type3": [300, 70] }, + "North America,US": { "type0": [100, null], "type2": [200, null], "type3": [300, 70], "type4": [350, null] }, "North America,CA": { "type0": [400, null], "type2": [500, null], "type3": [600, 70] }, - "North America,US,California": { "type0": [700, null], "type3": [800, 70] }, + "North America,US,California": { "type0": [700, null], "type3": [800, 70], "type4": [850, null] }, "North America,US,Florida": { "type0": [900, null], "type3": [1000, 70] }, "North America,US,Georgia": { "type0": [1100, null], "type3": [1200, null] }, - "Asia,SG": { "type0": [10000, 100], "type2": [11000, 100], "type3": [12000, 70] }, + "Asia,SG": { "type0": [10000, 100], "type2": [11000, 100], "type3": [12000, 70], "type4": [13000, null] }, "Asia": { "type0": [13000, 100], "type2": [14000, 100], "type3": [15000, 70] }, - "Europe": { "type0": [20000, null], "type2": [21000, null], "type3": [22000, 70] } + "Europe": { "type0": [20000, null], "type2": [21000, null], "type3": [22000, 70], "type4": [25000, null] } }"#; let map: BTreeMap> = @@ -173,6 +173,15 @@ pub mod v2 { let rewards = europe.rates.get("type3").unwrap(); assert_eq!(rewards.xdr_permyriad_per_node_per_month, 22000); assert_eq!(rewards.reward_coefficient_percent, Some(70)); + + // Verify type4 is parsed correctly (flat rewards, no coefficient) + let rewards = europe.rates.get("type4").unwrap(); + assert_eq!(rewards.xdr_permyriad_per_node_per_month, 25000); + assert_eq!(rewards.reward_coefficient_percent, None); + + let rewards = us_california.rates.get("type4").unwrap(); + assert_eq!(rewards.xdr_permyriad_per_node_per_month, 850); + assert_eq!(rewards.reward_coefficient_percent, None); } #[test] diff --git a/rs/registry/canister/src/mutations/node_management/do_add_node.rs b/rs/registry/canister/src/mutations/node_management/do_add_node.rs index 09ac7d477fe4..8765ca2ecf40 100644 --- a/rs/registry/canister/src/mutations/node_management/do_add_node.rs +++ b/rs/registry/canister/src/mutations/node_management/do_add_node.rs @@ -248,6 +248,7 @@ fn validate_str_as_node_reward_type + Display>( "type3" => NodeRewardType::Type3, "type3.1" => NodeRewardType::Type3dot1, "type1.1" => NodeRewardType::Type1dot1, + "type4" => NodeRewardType::Type4, _ => return Err(format!("Invalid node type: {type_string}")), }) }