Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deferred staking rewards #1035

Merged
merged 49 commits into from
Dec 15, 2021
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
c001582
Take notes about what needs to change for deferred staking rewards
notlesh Nov 29, 2021
d13ccae
Start building out pay_one_collator_reward()
notlesh Nov 29, 2021
a08dcc0
First pass at pay_one_collator_reward()
notlesh Nov 30, 2021
9326624
Hook up pay_one_collator_reward() in on_initialize()
notlesh Dec 1, 2021
3e35591
Create handle_delayed_payouts() wrapper
notlesh Dec 1, 2021
ece7493
cargo fmt
notlesh Dec 1, 2021
df66a83
Comment out failing tests for now
notlesh Dec 1, 2021
262d5d1
Handle RewardPaymentDelay in deferred payments
notlesh Dec 1, 2021
33cb18e
First test for deferred staking payouts
notlesh Dec 1, 2021
e7d92b5
Begin paying deferred payments immediately after round change
notlesh Dec 3, 2021
10f737e
Clean up no_rewards_paid_until_after_reward_payment_delay_plus_one te…
notlesh Dec 3, 2021
0a5f14b
Add storage creation/removal test and related fixes
notlesh Dec 3, 2021
8a3ab3f
Sketch out "steady state" test
notlesh Dec 3, 2021
ef49b28
First pass at "steady state" round payout test
notlesh Dec 6, 2021
0683f73
Messy (and broken) attempt at fixing inflation each round for test
notlesh Dec 7, 2021
c48c18c
Handle imbalance so that total issuance is properly deducted
notlesh Dec 7, 2021
446a872
Update other tests to reflect payouts starting on round change
notlesh Dec 7, 2021
adaff3b
Clean up
notlesh Dec 7, 2021
22e1eb2
Introduce round_length > num_collators constraint, update some tests
notlesh Dec 7, 2021
373bf40
This is why we write tests
notlesh Dec 7, 2021
278296e
Allow round length == total_selected
notlesh Dec 7, 2021
52e7b65
Incremental: fix broken tests
notlesh Dec 7, 2021
18b8282
Fix remaining broken tests
notlesh Dec 7, 2021
109ddd4
Minor clean up
notlesh Dec 7, 2021
aa63916
No-effort attempt to update benchmarks
notlesh Dec 8, 2021
6977b3c
s/T::DbWeight/RocksDbWeight/
notlesh Dec 9, 2021
cb2ddd1
Add benchmark for pay_one_collator_reward
notlesh Dec 10, 2021
80b49bd
Incorporate pay_one_collator_reward weight
notlesh Dec 13, 2021
324d895
Update pallets/parachain-staking/src/lib.rs
notlesh Dec 13, 2021
94c3643
Better error name
notlesh Dec 13, 2021
436beb9
Rename on_initialize benchmarks
notlesh Dec 13, 2021
47dbc03
Fix tests
notlesh Dec 13, 2021
009ade0
Reduce number of "steady state" test loops
notlesh Dec 13, 2021
d2744fd
Undo package-lock.json mods
notlesh Dec 13, 2021
51042d6
Merge branch 'master' into notlesh-deferred-staking-rewards
notlesh Dec 13, 2021
85a0299
Move comment
notlesh Dec 13, 2021
81aba52
Make assertions earlier
notlesh Dec 13, 2021
0a33c5e
Where did these spaces come from?!
notlesh Dec 13, 2021
eb70b9a
cargo fmt
notlesh Dec 13, 2021
cfc3d81
Preserve comments in rustfmt-compatible way
notlesh Dec 13, 2021
a546d8e
Remove unused imports
notlesh Dec 13, 2021
8c04a7e
Remove unused variable
notlesh Dec 13, 2021
9f9d52d
Clean up
notlesh Dec 13, 2021
4bf4efb
Add pallet documentation related to deferred payouts
notlesh Dec 13, 2021
d575a93
Remove unused import
notlesh Dec 13, 2021
a64c25d
Remove outdated TODO comment
notlesh Dec 14, 2021
b1e404b
Clean up copy pasta
notlesh Dec 14, 2021
61f90fe
Fixes for pay_one_collator_reward benchmark
notlesh Dec 14, 2021
9770b22
Update weights with official benchmarks
notlesh Dec 14, 2021
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
182 changes: 151 additions & 31 deletions pallets/parachain-staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ pub mod pallet {
pub total: Balance,
}

#[derive(Default, Encode, Decode, RuntimeDebug, TypeInfo)]
/// Info needed to make delayed payments to stakers after round end
pub struct DelayedPayout<Balance> {
/// Total round reward (result of compute_issuance() at round end)
pub round_issuance: Balance,
/// The total inflation paid this round to stakers (e.g. less parachain bond fund)
pub total_staking_reward: Balance,
/// Snapshot of collator commission rate at the end of the round
pub collator_commission: Perbill,
}

#[derive(Encode, Decode, RuntimeDebug, TypeInfo)]
/// DEPRECATED
/// Collator state with commission fee, bonded stake, and delegations
Expand Down Expand Up @@ -1480,9 +1491,18 @@ pub mod pallet {

#[pallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {

fn on_initialize(n: T::BlockNumber) -> Weight {
println!("on_initialize({})", n);

// base weight of a "no-op" on_initialize() call
// TODO: rewrite passive_on_initialize() and active_on_initialize() benchmarks to work as
// described here
4meta5 marked this conversation as resolved.
Show resolved Hide resolved
let mut weight = T::WeightInfo::passive_on_initialize();

let mut round = <Round<T>>::get();
if round.should_update(n) {
println!(" - round {} needs update", round.current);
// mutate round
round.update(n);
// pay all stakers for T::RewardPaymentDelay rounds ago
Expand All @@ -1500,10 +1520,17 @@ pub mod pallet {
collator_count,
total_staked,
));
T::WeightInfo::active_on_initialize(collator_count, delegation_count)
} else {
T::WeightInfo::passive_on_initialize()
// TODO: update active_on_initialize
// OR: benchmark the individual functions here and add up their weight
notlesh marked this conversation as resolved.
Show resolved Hide resolved
weight += T::WeightInfo::active_on_initialize(collator_count, delegation_count);
}

if Self::handle_delayed_payouts(round.current) {
// TODO: let handle_delayed_payouts return its own weight
weight += T::WeightInfo::passive_on_initialize()
}

weight
}
}

Expand Down Expand Up @@ -1609,6 +1636,12 @@ pub mod pallet {
ValueQuery,
>;

#[pallet::storage]
#[pallet::getter(fn delayed_payouts)]
/// Delayed payouts
pub type DelayedPayouts<T: Config> =
StorageMap<_, Twox64Concat, RoundIndex, DelayedPayout<BalanceOf<T>>, OptionQuery>;

#[pallet::storage]
#[pallet::getter(fn staked)]
/// Total counted stake for selected candidates in the round
Expand Down Expand Up @@ -2361,18 +2394,29 @@ pub mod pallet {
));
Ok(())
}
// TODO: rename this; split it up so that it does minimum round-end accounting, should set
// up distribution storage info
fn pay_stakers(now: RoundIndex) {
// payout is now - duration rounds ago => now - duration > 0 else return early
let duration = T::RewardPaymentDelay::get();
if now <= duration {
// payout is now - delay rounds ago => now - delay > 0 else return early
let delay = T::RewardPaymentDelay::get();
if now <= delay {
println!("!!! pay_stakers bailing because now <= delay!");
return;
}
let round_to_payout = now - duration;
let total = <Points<T>>::take(round_to_payout);
if total.is_zero() {
let round_to_payout = now - delay;
let total_points = <Points<T>>::get(round_to_payout);
if total_points.is_zero() {
// TODO: this perhaps being used to ensure we don't call this fn more than once per
// round. now thet we keep the Points storage item around until round payouts are
// done, this check would no longer work here (perhaps resulting in multiple payouts
// to parachain_bond_reserve.
//
// Potential alternative: drain() Points instead of get() (as we previously did) but
// duplicate instead -- e.g. we probably want to store 'left_issuance' this way
println!("!!! pay_stakers bailing because total_points is zero!");
return;
}
let total_staked = <Staked<T>>::take(round_to_payout);
let total_staked = <Staked<T>>::take(round_to_payout); // TODO: leave until payouts done
let total_issuance = Self::compute_issuance(total_staked);
let mut left_issuance = total_issuance;
// reserve portion of issuance for parachain bond account
Expand All @@ -2382,34 +2426,105 @@ pub mod pallet {
T::Currency::deposit_into_existing(&bond_config.account, parachain_bond_reserve)
{
// update round issuance iff transfer succeeds
left_issuance -= imb.peek();
left_issuance -= imb.peek(); // TODO: save left_issuance for payouts in later blocks
Self::deposit_event(Event::ReservedForParachainBond(
bond_config.account,
imb.peek(),
));
}

println!("Inserting DelayedPayout for round {}", round_to_payout);
<DelayedPayouts<T>>::insert(
round_to_payout,
DelayedPayout {
round_issuance: total_issuance,
total_staking_reward: left_issuance,
collator_commission: <CollatorCommission<T>>::get(),
},
);
}

/// Wrapper around pay_one_collator_reward which handles the following logic:
/// * whether or not a payout needs to be made
/// * cleaning up when payouts are done
/// * returns whether or not a payout was made
///
/// TODO: settle on (and document) return value
fn handle_delayed_payouts(now: RoundIndex) -> bool {
println!("handle_delayed_payouts({})", now);
// cases:
// 1. payouts doesn't exist: nothing to do
// caveat/TODO: on first round after this upgrade, this will be true and we won't
// clean up? a migration could solve this...
// 2. payouts exists and we aren't done
// 3. payouts exists and we are done (and we should clean up)

// we call the current round the "payout_round" and the round we are paying out for the
// "paid_for_round".
// payout_round is paid_for_round + RewardPaymentDelay + 1 and starts at the first
// block.

let delay = T::RewardPaymentDelay::get();
// don't underflow uint
if now < delay + 1 {
return false;
}

let paid_for_round = now - (delay + 1);

if let Some(payout_info) = <DelayedPayouts<T>>::get(paid_for_round) {
println!(" - have payout info, calling pay_one_collator_reward...");
if let None = Self::pay_one_collator_reward(paid_for_round, payout_info) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if let None = Self::pay_one_collator_reward(paid_for_round, payout_info) {
// This call returns none when all collators who were due payment have been paid.
if let None = Self::pay_one_collator_reward(paid_for_round, payout_info) {

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The most recent code includes some comments about this, but feel free to add more clarity

println!("Done paying out stakers for previous round ({})", paid_for_round);

// clean up storage items that we no longer need
<DelayedPayouts<T>>::remove(paid_for_round);
<Points<T>>::remove(paid_for_round);
}
true
} else {
println!(" - no payout info");
false
}
}

/// Payout a single collator from the given round.
///
/// Returns an optional tuple of (Collator's AccountId, total paid)
/// or None if there were no more payouts to be made for the round.
fn pay_one_collator_reward(
paid_for_round: RoundIndex,
payout_info: DelayedPayout<BalanceOf<T>>,
) -> Option<(T::AccountId, BalanceOf<T>)> {
println!("pay_one_collator_reward, round: {}", paid_for_round);
// TODO: it would probably be optimal to roll Points into the DelayedPayouts storage item
// so that we do fewer reads each block
let total_points = <Points<T>>::get(paid_for_round);
4meta5 marked this conversation as resolved.
Show resolved Hide resolved
if total_points.is_zero() {
// TODO: this case is obnoxious... it's a value query, so it could mean one of two
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason not to switch to OptionQuery?

Copy link
Contributor

Choose a reason for hiding this comment

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

It would require a migration

Copy link
Collaborator

Choose a reason for hiding this comment

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

No it doesn't require any migration because it doesn't change the way the data is stored, just the way it is read.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It might be worth refactoring these storage items to be OptionQuery but that is definitely out of scope here. I think that would improve some readability (like here).

// different logic errors:
// 1. we removed it before we should have
// 2. we called pay_one_collator_reward when we were actually done with deferred
// payouts
log::warn!("pay_one_collator_reward called with no <Points<T>> for the round!");
return None;
}

let mint = |amt: BalanceOf<T>, to: T::AccountId| {
if let Ok(amount_transferred) = T::Currency::deposit_into_existing(&to, amt) {
Self::deposit_event(Event::Rewarded(to.clone(), amount_transferred.peek()));
}
};
// only pay out rewards at the end to transfer only total amount due
let mut due_rewards: BTreeMap<T::AccountId, BalanceOf<T>> = BTreeMap::new();
let mut increase_due_rewards = |amt: BalanceOf<T>, to: T::AccountId| {
if let Some(already_due) = due_rewards.get(&to) {
let amount = amt.saturating_add(*already_due);
due_rewards.insert(to, amount);
} else {
due_rewards.insert(to, amt);
}
};
let collator_fee = <CollatorCommission<T>>::get();
let collator_issuance = collator_fee * total_issuance;
for (collator, pts) in <AwardedPts<T>>::drain_prefix(round_to_payout) {
let pct_due = Perbill::from_rational(pts, total);
let mut amt_due = pct_due * left_issuance;

let collator_fee = payout_info.collator_commission;
let collator_issuance = collator_fee * payout_info.round_issuance;

if let Some((collator, pts)) = <AwardedPts<T>>::iter_prefix(paid_for_round).drain().next() {
librelois marked this conversation as resolved.
Show resolved Hide resolved
let pct_due = Perbill::from_rational(pts, total_points);
let total_paid = pct_due * payout_info.total_staking_reward;
let mut amt_due = total_paid;
// Take the snapshot of block author and delegations
let state = <AtStake<T>>::take(round_to_payout, &collator);
let state = <AtStake<T>>::get(paid_for_round, &collator);
notlesh marked this conversation as resolved.
Show resolved Hide resolved
if state.delegations.is_empty() {
// solo collator with no delegators
mint(amt_due, collator.clone());
Expand All @@ -2424,19 +2539,24 @@ pub mod pallet {
for Bond { owner, amount } in state.delegations {
let percent = Perbill::from_rational(amount, state.total);
let due = percent * amt_due;
increase_due_rewards(due, owner.clone());
mint(due, owner.clone());
Self::deposit_event(Event::DelegatorDueReward(
owner.clone(),
collator.clone(),
due,
));
}
}
}
for (delegator, total_due) in due_rewards {
mint(total_due, delegator);

<AtStake<T>>::remove(paid_for_round, collator.clone());

return Some((collator, total_paid));
} else {
// Note that storage is cleaned up in handle_delayed_payouts()
return None;
}
}

/// Compute the top `TotalSelected` candidates in the CandidatePool and return
/// a vec of their AccountIds (in the order of selection)
pub fn compute_top_candidates() -> Vec<T::AccountId> {
Expand Down
74 changes: 73 additions & 1 deletion pallets/parachain-staking/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ impl ExtBuilder {
self
}

/// add a delegation by providing a Vec of tuples where each tuple is a delegation expressing:
/// 1. delegator's AccountId
/// 2. collator's AccountId
/// 3. delegation amount
/// (Note that this is passed directly to GenesisConfig's build())
notlesh marked this conversation as resolved.
Show resolved Hide resolved
pub(crate) fn with_delegations(
mut self,
delegations: Vec<(AccountId, AccountId, Balance)>,
Expand Down Expand Up @@ -227,7 +232,9 @@ impl ExtBuilder {
}
}

pub(crate) fn roll_to(n: u64) {
/// Rolls to the desired block. Returns the number of blocks played.
pub(crate) fn roll_to(n: u64) -> u64 {
let mut num_blocks = 0;
while System::block_number() < n {
Stake::on_finalize(System::block_number());
Balances::on_finalize(System::block_number());
Expand All @@ -236,7 +243,26 @@ pub(crate) fn roll_to(n: u64) {
System::on_initialize(System::block_number());
Balances::on_initialize(System::block_number());
Stake::on_initialize(System::block_number());
num_blocks += 1;
}
num_blocks
}

/// Rolls block-by-block to the beginning of the specified round.
/// This will complete the block in which the round change occurs.
/// Returns the number of blocks played.
pub(crate) fn roll_to_round_begin(round: u64) -> u64 {
let block = (round - 1) * DefaultBlocksPerRound::get() as u64;
println!("rolling from {} to {}", System::block_number(), block);
roll_to(block)
}

/// Rolls block-by-block to the end of the specified round.
/// The block following will be the one in which the specified round change occurs.
pub(crate) fn roll_to_round_end(round: u64) -> u64 {
let block = round * DefaultBlocksPerRound::get() as u64 - 1;
println!("rolling from {} to {}", System::block_number(), block);
roll_to(block)
}

pub(crate) fn last_event() -> Event {
Expand Down Expand Up @@ -385,3 +411,49 @@ fn geneses() {
}
});
}

#[test]
fn roll_to_round_begin_works() {
ExtBuilder::default()
.build()
.execute_with(|| {
// these tests assume blocks-per-round of 5, as established by DefaultBlocksPerRound
assert_eq!(System::block_number(), 1); // we start on block 1

let num_blocks = roll_to_round_begin(1);
assert_eq!(System::block_number(), 1); // no-op, we're already on this round
assert_eq!(num_blocks, 0);

let num_blocks = roll_to_round_begin(2);
assert_eq!(System::block_number(), 5);
assert_eq!(num_blocks, 4);

let num_blocks = roll_to_round_begin(3);
assert_eq!(System::block_number(), 10);
assert_eq!(num_blocks, 5);
});

}

#[test]
fn roll_to_round_end_works() {
ExtBuilder::default()
.build()
.execute_with(|| {
// these tests assume blocks-per-round of 5, as established by DefaultBlocksPerRound
assert_eq!(System::block_number(), 1); // we start on block 1

let num_blocks = roll_to_round_end(1);
assert_eq!(System::block_number(), 4);
assert_eq!(num_blocks, 3);

let num_blocks = roll_to_round_end(2);
assert_eq!(System::block_number(), 9);
assert_eq!(num_blocks, 5);

let num_blocks = roll_to_round_end(3);
assert_eq!(System::block_number(), 14);
assert_eq!(num_blocks, 5);
});

}
Loading