Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions rs/nns/integration_tests/src/node_provider_remuneration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
},
}
}
};
Expand Down
2 changes: 2 additions & 0 deletions rs/node_rewards/node_provider_reward_calculations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions rs/protobuf/def/registry/node/v1/node.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading