Skip to content
This repository has been archived by the owner on Nov 15, 2023. It is now read-only.

[pallet-staking] Refund unused weight for payout_stakers #8458

Merged
11 commits merged into from
Mar 28, 2021
49 changes: 36 additions & 13 deletions frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ use codec::{HasCompact, Encode, Decode};
use frame_support::{
decl_module, decl_event, decl_storage, ensure, decl_error,
weights::{
Weight,
Weight, WithPostDispatchInfo,
constants::{WEIGHT_PER_MICROS, WEIGHT_PER_NANOS},
},
storage::IterableStorageMap,
Expand Down Expand Up @@ -1831,8 +1831,8 @@ decl_module! {
/// NOTE: weights are assuming that payouts are made to alive stash account (Staked).
/// Paying even a dead controller is cheaper weight-wise. We don't do any refunds here.
/// # </weight>
#[weight = T::WeightInfo::payout_stakers_alive_staked(T::MaxNominatorRewardedPerValidator::get())]
fn payout_stakers(origin, validator_stash: T::AccountId, era: EraIndex) -> DispatchResult {
#[weight = T::WeightInfo::payout_stakers_alive_staked(T::MaxNominatorRewardedPerValidator::get() + 1)]
shawntabrizi marked this conversation as resolved.
Show resolved Hide resolved
fn payout_stakers(origin, validator_stash: T::AccountId, era: EraIndex) -> DispatchResultWithPostInfo {
ensure_signed(origin)?;
Self::do_payout_stakers(validator_stash, era)
}
Expand Down Expand Up @@ -1996,24 +1996,39 @@ impl<T: Config> Module<T> {
})
}

fn do_payout_stakers(validator_stash: T::AccountId, era: EraIndex) -> DispatchResult {
fn do_payout_stakers(validator_stash: T::AccountId, era: EraIndex) -> DispatchResultWithPostInfo {
// Validate input data
let current_era = CurrentEra::get().ok_or(Error::<T>::InvalidEraToReward)?;
ensure!(era <= current_era, Error::<T>::InvalidEraToReward);
let current_era = CurrentEra::get().ok_or(
Error::<T>::InvalidEraToReward.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
)?;
ensure!(
era <= current_era,
Error::<T>::InvalidEraToReward.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
);
let history_depth = Self::history_depth();
ensure!(era >= current_era.saturating_sub(history_depth), Error::<T>::InvalidEraToReward);
ensure!(
era >= current_era.saturating_sub(history_depth),
Error::<T>::InvalidEraToReward.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this is not really accurate, because by this point you've done 2 DB reads and almost nothing more, while T::WeightInfo::payout_stakers_alive_staked(0) is probably a lot more.

I am fine with this, since it is still a worse case, which is good.

Also fine with Error::<T>::InvalidEraToReward.with_weight(T::DbWeight::get().read(X))

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think incorrect extrinsics are perfectly fine to overcharge a bit. A simplification like the one you are mentioning risks being under valued, and thus opens an attack.

);
emostov marked this conversation as resolved.
Show resolved Hide resolved

// Note: if era has no reward to be claimed, era may be future. better not to update
// `ledger.claimed_rewards` in this case.
let era_payout = <ErasValidatorReward<T>>::get(&era)
.ok_or_else(|| Error::<T>::InvalidEraToReward)?;

let controller = Self::bonded(&validator_stash).ok_or(Error::<T>::NotStash)?;
.ok_or_else(||
Error::<T>::InvalidEraToReward
.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
)?;

let controller = Self::bonded(&validator_stash).ok_or(
Error::<T>::NotStash.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
)?;
let mut ledger = <Ledger<T>>::get(&controller).ok_or_else(|| Error::<T>::NotController)?;

ledger.claimed_rewards.retain(|&x| x >= current_era.saturating_sub(history_depth));
match ledger.claimed_rewards.binary_search(&era) {
Ok(_) => Err(Error::<T>::AlreadyClaimed)?,
Ok(_) => Err(
Error::<T>::AlreadyClaimed.with_weight(T::WeightInfo::payout_stakers_alive_staked(0))
)?,
Err(pos) => ledger.claimed_rewards.insert(pos, era),
}

Expand All @@ -2037,7 +2052,9 @@ impl<T: Config> Module<T> {
.unwrap_or_else(|| Zero::zero());

// Nothing to do if they have no reward points.
if validator_reward_points.is_zero() { return Ok(())}
if validator_reward_points.is_zero() {
return Ok(Some(T::WeightInfo::payout_stakers_alive_staked(0)).into())
}

// This is the fraction of the total reward that the validator and the
// nominators will get.
Expand All @@ -2062,11 +2079,15 @@ impl<T: Config> Module<T> {
);
let validator_staking_payout = validator_exposure_part * validator_leftover_payout;

// Track the number of payouts in order to track the actual weight.
let mut payout_count: u32 = 0;

// We can now make total validator payout:
if let Some(imbalance) = Self::make_payout(
&ledger.stash,
validator_staking_payout + validator_commission_payout
) {
payout_count += 1;
shawntabrizi marked this conversation as resolved.
Show resolved Hide resolved
Self::deposit_event(RawEvent::Reward(ledger.stash, imbalance.peek()));
}

Expand All @@ -2081,11 +2102,13 @@ impl<T: Config> Module<T> {
let nominator_reward: BalanceOf<T> = nominator_exposure_part * validator_leftover_payout;
// We can now make nominator payout:
if let Some(imbalance) = Self::make_payout(&nominator.who, nominator_reward) {
// Note: this logic does not count payouts for `RewardDestination::None`.
payout_count += 1;
Self::deposit_event(RawEvent::Reward(nominator.who.clone(), imbalance.peek()));
}
}

Ok(())
Ok(Some(T::WeightInfo::payout_stakers_alive_staked(payout_count)).into())
emostov marked this conversation as resolved.
Show resolved Hide resolved
}

/// Update the ledger for a controller.
Expand Down
2 changes: 2 additions & 0 deletions frame/staking/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ where
}

pub type Extrinsic = TestXt<Call, ()>;
pub(crate) type StakingCall = crate::Call<Test>;
pub(crate) type TestRuntimeCall = <Test as frame_system::Config>::Call;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should really just rename the Call generated by the construct_runtime! to OuterCall.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not realted to this PR, I am just ranting)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an issue open for this / should we open an issue?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no issue afaik.


pub struct ExtBuilder {
validator_pool: bool,
Expand Down
146 changes: 136 additions & 10 deletions frame/staking/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
use super::*;
use mock::*;
use sp_runtime::{
assert_eq_error_rate, traits::BadOrigin,
assert_eq_error_rate,
traits::{BadOrigin, Dispatchable},
};
use sp_staking::offence::OffenceDetails;
use frame_support::{
assert_ok, assert_noop, StorageMap,
traits::{Currency, ReservableCurrency, OnInitialize},
weights::{extract_actual_weight, GetDispatchInfo},
};
use pallet_balances::Error as BalancesError;
use substrate_test_utils::assert_eq_uvec;
Expand Down Expand Up @@ -2976,6 +2978,9 @@ fn claim_reward_at_the_last_era_and_no_double_claim_and_invalid_claim() {
// * an invalid era to claim doesn't update last_reward
// * double claim of one era fails
ExtBuilder::default().nominate(true).build_and_execute(|| {
// Consumed weight for all payout_stakers dispatches that fail
let err_weight = weights::SubstrateWeight::<Test>::payout_stakers_alive_staked(0);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should do the same patter in the main function as well, the code there is quite verbose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically, it would be one more computation that is not needed if the 0 weight is not used...


let init_balance_10 = Balances::total_balance(&10);
let init_balance_100 = Balances::total_balance(&100);

Expand Down Expand Up @@ -3021,19 +3026,19 @@ fn claim_reward_at_the_last_era_and_no_double_claim_and_invalid_claim() {
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 0),
// Fail: Era out of history
Error::<Test>::InvalidEraToReward
Error::<Test>::InvalidEraToReward.with_weight(err_weight)
);
assert_ok!(Staking::payout_stakers(Origin::signed(1337), 11, 1));
assert_ok!(Staking::payout_stakers(Origin::signed(1337), 11, 2));
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 2),
// Fail: Double claim
Error::<Test>::AlreadyClaimed
Error::<Test>::AlreadyClaimed.with_weight(err_weight)
);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, active_era),
// Fail: Era not finished yet
Error::<Test>::InvalidEraToReward
Error::<Test>::InvalidEraToReward.with_weight(err_weight)
);

// Era 0 can't be rewarded anymore and current era can't be rewarded yet
Expand Down Expand Up @@ -3287,6 +3292,9 @@ fn test_payout_stakers() {
fn payout_stakers_handles_basic_errors() {
// Here we will test payouts handle all errors.
ExtBuilder::default().has_stakers(false).build_and_execute(|| {
// Consumed weight for all payout_stakers dispatches that fail
let err_weight = weights::SubstrateWeight::<Test>::payout_stakers_alive_staked(0);

// Same setup as the test above
let balance = 1000;
bond_validator(11, 10, balance); // Default(64)
Expand All @@ -3305,9 +3313,15 @@ fn payout_stakers_handles_basic_errors() {
mock::start_active_era(2);

// Wrong Era, too big
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 11, 2), Error::<Test>::InvalidEraToReward);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 2),
Error::<Test>::InvalidEraToReward.with_weight(err_weight)
);
// Wrong Staker
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 10, 1), Error::<Test>::NotStash);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 10, 1),
Error::<Test>::NotStash.with_weight(err_weight)
);

for i in 3..100 {
Staking::reward_by_ids(vec![(11, 1)]);
Expand All @@ -3317,14 +3331,126 @@ fn payout_stakers_handles_basic_errors() {
}
// We are at era 99, with history depth of 84
// We should be able to payout era 15 through 98 (84 total eras), but not 14 or 99.
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 11, 14), Error::<Test>::InvalidEraToReward);
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 11, 99), Error::<Test>::InvalidEraToReward);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 14),
Error::<Test>::InvalidEraToReward.with_weight(err_weight)
);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 99),
Error::<Test>::InvalidEraToReward.with_weight(err_weight)
);
assert_ok!(Staking::payout_stakers(Origin::signed(1337), 11, 15));
assert_ok!(Staking::payout_stakers(Origin::signed(1337), 11, 98));

// Can't claim again
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 11, 15), Error::<Test>::AlreadyClaimed);
assert_noop!(Staking::payout_stakers(Origin::signed(1337), 11, 98), Error::<Test>::AlreadyClaimed);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 15),
Error::<Test>::AlreadyClaimed.with_weight(err_weight)
);
assert_noop!(
Staking::payout_stakers(Origin::signed(1337), 11, 98),
Error::<Test>::AlreadyClaimed.with_weight(err_weight)
);
});
}

#[test]
fn payout_stakers_handles_weight_refund() {
// N.B. this test relies on the assumption that `payout_stakers_alive_staked` is solely used to
shawntabrizi marked this conversation as resolved.
Show resolved Hide resolved
// calculate weight.
ExtBuilder::default().has_stakers(false).build_and_execute(|| {
let max_nom_rewarded = <Test as Config>::MaxNominatorRewardedPerValidator::get();
emostov marked this conversation as resolved.
Show resolved Hide resolved
let half_max_nom_rewarded = max_nom_rewarded.checked_div(2).unwrap();
emostov marked this conversation as resolved.
Show resolved Hide resolved
// We add 1 to account for the payout ops to the validator
let max_nom_rewarded_weight
= <Test as Config>::WeightInfo::payout_stakers_alive_staked(max_nom_rewarded +1);
let half_max_nom_rewarded_weight
= <Test as Config>::WeightInfo::payout_stakers_alive_staked(half_max_nom_rewarded + 1);
let zero_payouts_weight = <Test as Config>::WeightInfo::payout_stakers_alive_staked(0);
emostov marked this conversation as resolved.
Show resolved Hide resolved

let balance = 1000;
bond_validator(11, 10, balance);

/* Era 1*/
start_active_era(1);

// Reward just the validator.
Staking::reward_by_ids(vec![(11, 1)]);

// Add some `half_max_nom_rewarded` nominators who will start backing the validator in the
// next era.
for i in 0..half_max_nom_rewarded {
bond_nominator((1000 + i).into(), (100 + i).into(), balance + i as Balance, vec![11]);
}

/* Era 2 */
start_active_era(2);

// Collect payouts when there are no nominators
let call = TestRuntimeCall::Staking(StakingCall::payout_stakers(11, 1));
let info = call.get_dispatch_info();
let result = call.dispatch(Origin::signed(20));
assert_ok!(result);
assert_eq!(
extract_actual_weight(&result, &info),
<Test as Config>::WeightInfo::payout_stakers_alive_staked(1)
);

// The validator is not rewarded in this era; so there will be zero payouts to claim for this era.

/* Era 3 */
start_active_era(3);

// Collect payouts for an era where the validator did not receive any points.
let call = TestRuntimeCall::Staking(StakingCall::payout_stakers(11, 2));
let info = call.get_dispatch_info();
let result = call.dispatch(Origin::signed(20));
assert_ok!(result);
assert_eq!(extract_actual_weight(&result, &info), zero_payouts_weight);

// Reward the validator and its nominators.
Staking::reward_by_ids(vec![(11, 1)]);

/* Era 4 */
start_active_era(4);

// Collect payouts when the validator has `half_max_nom_rewarded` nominators.
let call = TestRuntimeCall::Staking(StakingCall::payout_stakers(11, 3));
let info = call.get_dispatch_info();
let result = call.dispatch(Origin::signed(20));
assert_ok!(result);
assert_eq!(extract_actual_weight(&result, &info), half_max_nom_rewarded_weight);

// Add enough nominators so that we are at the limit. They will be active nominators
// in the next era.
for i in half_max_nom_rewarded..max_nom_rewarded {
bond_nominator((1000 + i).into(), (100 + i).into(), balance + i as Balance, vec![11]);
}

/* Era 5 */
start_active_era(5);
// We now have `max_nom_rewarded` nominators actively nominating our validator.

// Reward the validator so we can collect for everyone in the next era.
Staking::reward_by_ids(vec![(11, 1)]);

/* Era 6 */
start_active_era(6);

// Collect payouts when the validator had `half_max_nom_rewarded` nominators.
let call = TestRuntimeCall::Staking(StakingCall::payout_stakers(11, 5));
let info = call.get_dispatch_info();
let result = call.dispatch(Origin::signed(20));
assert_ok!(result);
assert_eq!(extract_actual_weight(&result, &info), max_nom_rewarded_weight);

// Try and collect payouts for an era that has already been collected.
let call = TestRuntimeCall::Staking(StakingCall::payout_stakers(11, 5));
let info = call.get_dispatch_info();
let result = call.dispatch(Origin::signed(20));
assert!(result.is_err());
// When there is an error the consumed weight == weight when there are 0 payouts.
assert_eq!(extract_actual_weight(&result, &info), zero_payouts_weight);
});
}

Expand Down