use super::*;
use crate::{
asset,
session_rotation::{EraElectionPlanner, Eras, Rotator},
};
#[test]
fn config_set_noop_remove_works() {
ExtBuilder::default().build_and_execute(|| {
assert_ok!(Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::root(),
ConfigOp::Set(30_000),
ConfigOp::Set(100_000),
ConfigOp::Set(Perbill::from_rational(1u32, 2u32)),
));
assert_eq!(OptimumSelfStake::<Test>::get(), 30_000);
assert_eq!(HardCapSelfStake::<Test>::get(), 100_000);
assert_eq!(SelfStakeSlopeFactor::<Test>::get(), Perbill::from_rational(1u32, 2u32));
assert!(staking_events_since_last_call().iter().any(|e| matches!(
e,
Event::ValidatorIncentiveConfigSet {
optimum_self_stake: 30_000,
hard_cap_self_stake: 100_000,
slope_factor,
} if *slope_factor == Perbill::from_rational(1u32, 2u32)
)));
assert_storage_noop!(assert_ok!(Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::root(),
ConfigOp::Noop,
ConfigOp::Noop,
ConfigOp::Noop,
)));
assert_ok!(Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::root(),
ConfigOp::Remove,
ConfigOp::Remove,
ConfigOp::Remove,
));
assert!(!OptimumSelfStake::<Test>::exists());
assert!(!HardCapSelfStake::<Test>::exists());
assert!(!SelfStakeSlopeFactor::<Test>::exists());
});
}
#[test]
fn config_requires_admin_origin() {
ExtBuilder::default().build_and_execute(|| {
let admin = 1;
assert_noop!(
Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::signed(2),
ConfigOp::Set(30_000),
ConfigOp::Set(100_000),
ConfigOp::Set(Perbill::from_rational(1u32, 2u32)),
),
DispatchError::BadOrigin
);
assert_ok!(Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::signed(admin),
ConfigOp::Set(30_000),
ConfigOp::Set(100_000),
ConfigOp::Set(Perbill::from_rational(1u32, 2u32)),
));
});
}
#[test]
fn config_validates_optimum_le_cap() {
ExtBuilder::default().build_and_execute(|| {
assert_noop!(
Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::root(),
ConfigOp::Set(100_000),
ConfigOp::Set(50_000),
ConfigOp::Set(Perbill::from_rational(1u32, 2u32)),
),
Error::<Test>::OptimumGreaterThanCap
);
assert_ok!(Staking::set_validator_self_stake_incentive_config(
RuntimeOrigin::root(),
ConfigOp::Set(50_000),
ConfigOp::Set(50_000),
ConfigOp::Set(Perbill::from_rational(1u32, 2u32)),
));
});
}
#[test]
fn validator_receives_both_staker_and_incentive_rewards() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11; let bob = 101;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
let era_pot = <Test as Config>::RewardPots::pot_account(RewardPot::Era(
2,
RewardKind::ValidatorSelfStake,
));
let budget = ErasValidatorIncentiveBudget::<Test>::get(2);
assert_eq!(Balances::free_balance(&era_pot), budget);
let alice_before = asset::total_balance::<Test>(&alice);
make_all_reward_payment(2);
let events = staking_events_since_last_call();
let staker = staker_reward_for(alice, &events).expect("staker reward");
let incentive = incentive_paid_for(alice, &events).expect("incentive bonus");
assert_eq!(asset::total_balance::<Test>(&alice) - alice_before, staker + incentive);
let bob_reward = staker_reward_for(bob, &events).expect("nominator should receive reward");
assert!(
bob_reward < staker,
"nominator ({bob_reward}) should get less than validator ({staker})"
);
assert!(incentive_paid_for(bob, &events).is_none());
let total_incentive_paid: Balance = events
.iter()
.filter_map(|e| match e {
Event::ValidatorIncentivePaid { amount, .. } => Some(*amount),
_ => None,
})
.sum();
assert_eq!(Balances::free_balance(&era_pot), budget - total_incentive_paid);
assert_eq!(Balances::free_balance(&general_incentive_pot()), ExistentialDeposit::get());
});
}
#[test]
fn no_incentive_when_budget_is_zero() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(50, 0);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(2);
let _ = staking_events_since_last_call();
make_all_reward_payment(1);
let events = staking_events_since_last_call();
assert!(staker_reward_for(alice, &events).is_some());
assert!(incentive_paid_for(alice, &events).is_none());
assert_eq!(ErasValidatorIncentiveBudget::<Test>::get(1), 0);
});
}
#[test]
fn enabling_incentive_budget_mid_flight() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(50, 0);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(2);
let _ = staking_events_since_last_call();
make_all_reward_payment(1);
let era1 = staking_events_since_last_call();
assert!(incentive_paid_for(alice, &era1).is_none());
setup_incentive_with_budget(40, 10);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
make_all_reward_payment(2);
let era2 = staking_events_since_last_call();
assert!(incentive_paid_for(alice, &era2).is_some());
assert_eq!(ErasValidatorIncentiveBudget::<Test>::get(1), 0);
let actual_budget = ErasValidatorIncentiveBudget::<Test>::get(2);
let expected = Perbill::from_percent(10).mul_floor(total_payout_for(time_per_era()));
assert_eq_error_rate!(actual_budget, expected, 1);
});
}
#[test]
fn zero_reward_points_means_no_payout() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11; let bob = 21;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(bob, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
let bob_weight = ErasValidatorIncentiveWeight::<Test>::get(2, bob).unwrap();
let budget = ErasValidatorIncentiveBudget::<Test>::get(2);
assert_eq!(ErasValidatorIncentiveWeight::<Test>::get(2, alice).unwrap(), bob_weight);
assert_eq!(ErasSumValidatorIncentiveWeight::<Test>::get(2), 2 * bob_weight);
assert_eq!(budget, 750);
let pot: AccountId = <Test as Config>::RewardPots::pot_account(RewardPot::Era(
2,
RewardKind::ValidatorSelfStake,
));
make_all_reward_payment(2);
let events = staking_events_since_last_call();
assert_eq!(staker_reward_for(alice, &events), None);
assert_eq!(incentive_paid_for(alice, &events), None);
assert!(staker_reward_for(bob, &events).unwrap() > budget / 2);
assert_eq!(incentive_paid_for(bob, &events), Some(budget / 2));
assert_eq!(Balances::free_balance(&pot), budget / 2);
});
}
#[test]
fn incentive_weight_stored_correctly() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let incentive_weight = ErasValidatorIncentiveWeight::<Test>::get(2, alice).unwrap();
assert_eq!(incentive_weight, 31);
let _ = staking_events_since_last_call();
make_all_reward_payment(2);
assert_eq!(incentive_paid_for(alice, &staking_events_since_last_call()), Some(375));
});
}
#[test]
fn incentive_paid_to_custom_account() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11; let reward_account = 999;
assert_ok!(Staking::set_payee(
RuntimeOrigin::signed(alice),
RewardDestination::Account(reward_account)
));
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
let before = asset::total_balance::<Test>(&reward_account);
make_all_reward_payment(2);
let events = staking_events_since_last_call();
let (incentive, dest) = incentive_paid_details(alice, &events).expect("incentive");
assert_eq!(dest, RewardDestination::Account(reward_account));
let staker = staker_reward_for(alice, &events).expect("staker reward");
assert_eq!(asset::total_balance::<Test>(&reward_account) - before, staker + incentive);
});
}
#[test]
fn multi_page_election_does_not_overwrite_incentive_weight() {
ExtBuilder::default()
.multi_page_election_provider(3)
.exposures_page_size(1)
.build_and_execute(|| {
let alice = 11; setup_incentive_config();
Session::roll_to_next_session();
let planned_era = Rotator::<Test>::planned_era();
hypothetically!({
let page1 = bounded_vec![(
alice,
Exposure {
total: 1250,
own: 1000,
others: vec![IndividualExposure { who: 101, value: 250 }]
},
)];
EraElectionPlanner::<Test>::store_stakers_info(page1, planned_era);
let incentive_weight =
ErasValidatorIncentiveWeight::<Test>::get(planned_era, alice).unwrap();
assert_eq!(incentive_weight, 31);
let page2 = bounded_vec![(
alice,
Exposure {
total: 250,
own: 0,
others: vec![IndividualExposure { who: 102, value: 250 }]
},
)];
EraElectionPlanner::<Test>::store_stakers_info(page2, planned_era);
assert_eq!(
ErasValidatorIncentiveWeight::<Test>::get(planned_era, alice).unwrap(),
incentive_weight
);
});
hypothetically!({
let page1 = bounded_vec![(
alice,
Exposure {
total: 250,
own: 0,
others: vec![IndividualExposure { who: 101, value: 250 }]
},
)];
EraElectionPlanner::<Test>::store_stakers_info(page1, planned_era);
assert_eq!(ErasValidatorIncentiveWeight::<Test>::get(planned_era, alice), None);
let page2 = bounded_vec![(
alice,
Exposure {
total: 1250,
own: 1000,
others: vec![IndividualExposure { who: 102, value: 250 }]
},
)];
EraElectionPlanner::<Test>::store_stakers_info(page2, planned_era);
let incentive_weight =
ErasValidatorIncentiveWeight::<Test>::get(planned_era, alice).unwrap();
assert_eq!(incentive_weight, 31); });
});
}
#[test]
fn multiple_validators_share_incentive_pot_correctly() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11; let bob = 21;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (bob, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
let pot_snapshot = ErasValidatorIncentiveBudget::<Test>::get(2);
let expected = Perbill::from_percent(5).mul_floor(total_payout_for(time_per_era()));
assert_eq_error_rate!(pot_snapshot, expected, 1);
let alice_incentive_weight = ErasValidatorIncentiveWeight::<Test>::get(2, alice).unwrap();
let bob_incentive_weight = ErasValidatorIncentiveWeight::<Test>::get(2, bob).unwrap();
let sum_incentive_weight = ErasSumValidatorIncentiveWeight::<Test>::get(2);
let alice_expected = Perbill::from_rational(alice_incentive_weight, sum_incentive_weight)
.mul_floor(pot_snapshot);
let bob_expected = Perbill::from_rational(bob_incentive_weight, sum_incentive_weight)
.mul_floor(pot_snapshot);
make_all_reward_payment(2);
let pot_account = <Test as Config>::RewardPots::pot_account(RewardPot::Era(
2,
RewardKind::ValidatorSelfStake,
));
let remaining = Balances::free_balance(&pot_account);
let total_claimed = pot_snapshot - remaining;
let expected_total = alice_expected + bob_expected;
assert!(total_claimed <= expected_total);
assert!(expected_total - total_claimed < 5, "Rounding dust too large");
});
}
#[test]
fn validator_incentive_prorated_across_pages() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
let validator_incentive_weight =
ErasValidatorIncentiveWeight::<Test>::get(2, alice).unwrap();
let sum_incentive_weight = ErasSumValidatorIncentiveWeight::<Test>::get(2);
let pot = ErasValidatorIncentiveBudget::<Test>::get(2);
let expected_total =
Perbill::from_rational(validator_incentive_weight, sum_incentive_weight).mul_floor(pot);
make_all_reward_payment(2);
let events = staking_events_since_last_call();
let total_paid: Balance = events
.iter()
.filter_map(|e| match e {
Event::ValidatorIncentivePaid { validator_stash, amount, .. }
if *validator_stash == alice =>
{
Some(*amount)
},
_ => None,
})
.sum();
assert!(total_paid <= expected_total);
assert!(expected_total - total_paid < 5, "Rounding dust too large");
});
}
#[test]
fn chilled_validator_can_still_claim_past_era() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
assert!(ErasValidatorIncentiveWeight::<Test>::get(2, alice).is_some());
assert_ok!(Staking::chill(RuntimeOrigin::signed(alice)));
assert!(!Validators::<Test>::contains_key(&alice));
make_all_reward_payment(2);
let events = staking_events_since_last_call();
assert!(
incentive_paid_for(alice, &events).is_some(),
"Chilled validator should still receive incentive for past era"
);
});
}
#[test]
fn payee_change_before_payout_uses_new_destination() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11; let old_account = 888;
let new_account = 999;
assert_ok!(Staking::set_payee(
RuntimeOrigin::signed(alice),
RewardDestination::Account(old_account)
));
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let _ = staking_events_since_last_call();
assert_ok!(Staking::set_payee(
RuntimeOrigin::signed(alice),
RewardDestination::Account(new_account)
));
let old_before = asset::total_balance::<Test>(&old_account);
let new_before = asset::total_balance::<Test>(&new_account);
make_all_reward_payment(2);
let events = staking_events_since_last_call();
let (incentive, dest) = incentive_paid_details(alice, &events).expect("incentive");
assert_eq!(dest, RewardDestination::Account(new_account));
assert_eq!(asset::total_balance::<Test>(&old_account), old_before);
assert!(asset::total_balance::<Test>(&new_account) - new_before >= incentive);
});
}
#[test]
fn all_validators_zero_points_no_incentive_paid() {
ExtBuilder::default().build_and_execute(|| {
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
let _ = staking_events_since_last_call();
make_all_reward_payment(1);
let events = staking_events_since_last_call();
assert!(
!events.iter().any(|e| matches!(e, Event::ValidatorIncentivePaid { .. })),
"No incentive when no validators earned reward points"
);
});
}
#[test]
fn missing_payee_emits_unexpected_and_skips_payout() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
Payee::<Test>::remove(&alice);
let _ = staking_events_since_last_call();
make_all_reward_payment(2);
let events = staking_events_since_last_call();
assert!(events
.contains(&Event::Unexpected(UnexpectedKind::MissingPayee { era: 2, stash: alice })));
assert!(incentive_paid_for(alice, &events).is_none());
assert!(incentive_paid_for(21, &events).is_some());
Payee::<Test>::insert(alice, RewardDestination::Staked);
});
}
#[test]
#[should_panic(expected = "Validator incentive liquid transfer failed")]
fn defensive_panic_on_transfer_failure() {
ExtBuilder::default().build_and_execute(|| {
let alice = 11;
setup_incentive_with_budget(45, 5);
Session::roll_until_active_era(2);
Eras::<Test>::reward_active_era(vec![(alice, 1), (21, 1)]);
Session::roll_until_active_era(3);
let pot = <Test as Config>::RewardPots::pot_account(RewardPot::Era(
2,
RewardKind::ValidatorSelfStake,
));
let pot_balance = Balances::free_balance(&pot);
if pot_balance > 0 {
let _ = <Balances as frame_support::traits::fungible::Mutate<_>>::transfer(
&pot,
&999,
pot_balance,
frame_support::traits::tokens::Preservation::Expendable,
);
}
make_all_reward_payment(2);
});
}