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

Automatic withdraw_unbonded upon unbond #12582

Merged
merged 22 commits into from
Dec 15, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9e569f5
Prevents max unbonding chunk slots from being filled when unbonding i…
gpestana Oct 29, 2022
a25ecaa
hardcode num_slashing to unlock chunks automatically
gpestana Nov 8, 2022
6e57960
refactor withdraw logic to do_withdraw; idiomatic rust improvements
gpestana Nov 8, 2022
e89a3cd
a
gpestana Nov 9, 2022
437ed30
callable unbond() to return a DispatchWithPostInfo to dynamically upd…
gpestana Nov 14, 2022
af00da5
refunds overpaid fees when unbond with withdraw
gpestana Nov 14, 2022
2fe20e4
fetches real slashing spans before withdrawal call
gpestana Nov 15, 2022
ffeb1b0
nits
gpestana Nov 15, 2022
d6b5adc
addresses PR comments
gpestana Nov 20, 2022
add919f
Adds more testing
gpestana Nov 21, 2022
90461ad
fixes doc comments
gpestana Nov 29, 2022
a12d351
Fixes weight refunding logic for fn unbond
gpestana Dec 1, 2022
8cc482a
generalizes to return used weight or dispatch error
gpestana Dec 2, 2022
a56794d
Update frame/staking/src/pallet/mod.rs
gpestana Dec 6, 2022
e209b7f
Update frame/staking/src/pallet/mod.rs
gpestana Dec 6, 2022
acaac84
Addresses PR comments
gpestana Dec 7, 2022
05d98f6
Add comment to speculative num spans
gpestana Dec 7, 2022
5222598
Merge branch 'master' into staking_interface-ensure_unbond
gpestana Dec 8, 2022
bbabc96
Merge branch 'master' into staking_interface-ensure_unbond
gpestana Dec 14, 2022
25317b5
adds missing add_slashing_spans in withdraw_unbonded_kill benchmarks
gpestana Dec 14, 2022
b376ad0
".git/.scripts/bench-bot.sh" pallet dev pallet_staking
Dec 14, 2022
b965691
fix publish
gpestana Dec 14, 2022
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
10 changes: 7 additions & 3 deletions frame/nomination-pools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1609,9 +1609,13 @@ pub mod pallet {
/// # Note
///
/// If there are too many unlocking chunks to unbond with the pool account,
/// [`Call::pool_withdraw_unbonded`] can be called to try and minimize unlocking chunks. If
/// there are too many unlocking chunks, the result of this call will likely be the
/// `NoMoreChunks` error from the staking system.
/// [`Call::pool_withdraw_unbonded`] can be called to try and minimize unlocking chunks.
/// The [`StakingInterface::unbond`] will implicitly call [`Call::pool_withdraw_unbonded`]
gpestana marked this conversation as resolved.
Show resolved Hide resolved
/// to try to free chunks if necessary (ie. if unbound was called and no unlocking chunks
/// are available). However, it may not be possible to release the current unlocking chunks,
/// in which case, the result of this call will likely be the `NoMoreChunks` error from the
/// staking system.

#[pallet::weight(T::WeightInfo::unbond())]
#[transactional]
pub fn unbond(
Expand Down
41 changes: 41 additions & 0 deletions frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ impl<T: Config> Pallet<T> {
Self::slashable_balance_of_vote_weight(who, issuance)
}

pub(super) fn do_withdraw_unbonded(
controller: &T::AccountId,
num_slashing_spans: u32,
) -> Result<Weight, DispatchError> {
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
let (stash, old_total) = (ledger.stash.clone(), ledger.total);
if let Some(current_era) = Self::current_era() {
ledger = ledger.consolidate_unlocked(current_era)
}

let used_weight =
if ledger.unlocking.is_empty() && ledger.active < T::Currency::minimum_balance() {
// This account must have called `unbond()` with some value that caused the active
// portion to fall below existential deposit + will have no more unlocking chunks
// left. We can now safely remove all staking-related information.
Self::kill_stash(&stash, num_slashing_spans)?;
// Remove the lock.
T::Currency::remove_lock(STAKING_ID, &stash);

T::WeightInfo::withdraw_unbonded_kill(num_slashing_spans)
} else {
// This was the consequence of a partial unbond. just update the ledger and move on.
Self::update_ledger(&controller, &ledger);

// This is only an update, so we use less overall weight.
T::WeightInfo::withdraw_unbonded_update(num_slashing_spans)
};

// `old_total` should never be less than the new total because
// `consolidate_unlocked` strictly subtracts balance.
if ledger.total < old_total {
// Already checked that this won't overflow by entry condition.
let value = old_total - ledger.total;
Self::deposit_event(Event::<T>::Withdrawn { stash, amount: value });
}

Ok(used_weight)
}

pub(super) fn do_payout_stakers(
validator_stash: T::AccountId,
era: EraIndex,
Expand Down Expand Up @@ -1542,6 +1581,8 @@ impl<T: Config> StakingInterface for Pallet<T> {
fn unbond(who: &Self::AccountId, value: Self::Balance) -> DispatchResult {
let ctrl = Self::bonded(who).ok_or(Error::<T>::NotStash)?;
Self::unbond(RawOrigin::Signed(ctrl).into(), value)
.map_err(|with_post| with_post.error)
.map(|_| ())
}

fn chill(who: &Self::AccountId) -> DispatchResult {
Expand Down
84 changes: 42 additions & 42 deletions frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

use frame_election_provider_support::{SortedListProvider, VoteWeight};
use frame_support::{
dispatch::Codec,
dispatch::{Codec, PostDispatchInfo},
pallet_prelude::*,
traits::{
Currency, CurrencyToVote, Defensive, DefensiveResult, DefensiveSaturating, EnsureOrigin,
Expand Down Expand Up @@ -49,6 +49,7 @@ use crate::{
};

const STAKING_ID: LockIdentifier = *b"staking ";
pub(crate) const SPECULATIVE_NUM_SPANS: u32 = 32;
Copy link
Contributor

Choose a reason for hiding this comment

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

May be some comment about why we speculate slashing spans would be 32? Is this the maximum it can be?

Copy link
Contributor

Choose a reason for hiding this comment

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

Can it be different for different runtimes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I believe there is no max for the number of spans. I pulled the numbers of current slashing spans and calculated a few metrics over it for Kusama an Polkadot, sharing here if you are interested:

Polkadot:
 sum: 8577
 avg: 1.314410480349345
 median: 1
 q75: 1
 q90: 2
 q999: 7.629000000000019

boundaries: [1, 9]
---

Kusama:
 sum: 602
 avg: 3.4626564392410173
 median: 2
 q75: 4
 q90: 8
 q999: 46.43199999999797
 boundaries: [1, 65]

qX is the quantile X, i.e. the value that stands at the position x% of the sorted array with all slashing spans.  

32 is a conservative number in Polkadot as you can see in the numbers above (max num spans so far is 9). In Kusama, I believe there were some issues in the past that triggered slashings, and that's why the max spans are larger. Given that we don't want to set the default dispatchable weight too high (to decrease the changes of a transaction being left out of a block due to weight over-provisioning that would be refunded post dispatch) and that the chances of both 1) a call require a unbond+withdraw and 2) slashing spans are > 32 , I believe this is a sensitive number. Even if the number of slashing spans is larger than the spaculative number and the withdraw is required before unbonding, there is no critical issues with it.

Copy link
Contributor Author

@gpestana gpestana Dec 7, 2022

Choose a reason for hiding this comment

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

I will add added a comment explaining what SPECULATIVE_NUM_SPANS are and why we set it to 32.


#[frame_support::pallet]
pub mod pallet {
Expand Down Expand Up @@ -113,7 +114,6 @@ pub mod pallet {
// we only accept an election provider that has staking as data provider.
DataProvider = Pallet<Self>,
>;

/// Something that provides the election functionality at genesis.
type GenesisElectionProvider: frame_election_provider_support::ElectionProvider<
AccountId = Self::AccountId,
Expand Down Expand Up @@ -932,29 +932,49 @@ pub mod pallet {
/// the funds out of management ready for transfer.
///
/// No more than a limited number of unlocking chunks (see `MaxUnlockingChunks`)
/// can co-exists at the same time. In that case, [`Call::withdraw_unbonded`] need
/// to be called first to remove some of the chunks (if possible).
/// can co-exists at the same time. If there are no unlocking chunks slots available
/// [`Call::withdraw_unbonded`] is called to remove some of the chunks (if possible).
///
/// If a user encounters the `InsufficientBond` error when calling this extrinsic,
/// they should call `chill` first in order to free up their bonded funds.
///
/// Emits `Unbonded`.
///
/// See also [`Call::withdraw_unbonded`].
#[pallet::weight(T::WeightInfo::unbond())]
#[pallet::weight(
T::WeightInfo::withdraw_unbonded_kill(SPECULATIVE_NUM_SPANS).saturating_add(T::WeightInfo::unbond()))
]
pub fn unbond(
origin: OriginFor<T>,
#[pallet::compact] value: BalanceOf<T>,
) -> DispatchResult {
) -> DispatchResultWithPostInfo {
let controller = ensure_signed(origin)?;
let unlocking = Self::ledger(&controller)
.map(|l| l.unlocking.len())
.ok_or(Error::<T>::NotController)?;

// ensure that there's chunk slots available by requesting the staking interface to
gpestana marked this conversation as resolved.
Show resolved Hide resolved
// withdraw chunks older than `BondingDuration`, if there are no more unlocking chunks
// slots available.
let maybe_withdraw_weight = {
kianenigma marked this conversation as resolved.
Show resolved Hide resolved
if unlocking == T::MaxUnlockingChunks::get() as usize {
let real_num_slashing_spans = Self::slashing_spans(&controller).iter().count();
Some(Self::do_withdraw_unbonded(&controller, real_num_slashing_spans as u32)?)
} else {
None
}
};

gpestana marked this conversation as resolved.
Show resolved Hide resolved
// we need to fetch the ledger again because it may have been mutated in the call
// to `Self::do_withdraw_unbonded` above.
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
gpestana marked this conversation as resolved.
Show resolved Hide resolved
let mut value = value.min(ledger.active);

ensure!(
ledger.unlocking.len() < T::MaxUnlockingChunks::get() as usize,
Error::<T>::NoMoreChunks,
);

let mut value = value.min(ledger.active);

if !value.is_zero() {
ledger.active -= value;

Expand Down Expand Up @@ -1002,7 +1022,14 @@ pub mod pallet {

Self::deposit_event(Event::<T>::Unbonded { stash: ledger.stash, amount: value });
}
Ok(())

let actual_weight = if let Some(withdraw_weight) = maybe_withdraw_weight {
Some(T::WeightInfo::unbond().saturating_add(withdraw_weight))
} else {
Some(T::WeightInfo::unbond())
};

Ok(PostDispatchInfo { actual_weight, pays_fee: Pays::Yes })
gpestana marked this conversation as resolved.
Show resolved Hide resolved
}

/// Remove any unlocked chunks from the `unlocking` queue from our management.
Expand All @@ -1026,40 +1053,13 @@ pub mod pallet {
num_slashing_spans: u32,
) -> DispatchResultWithPostInfo {
let controller = ensure_signed(origin)?;
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
let (stash, old_total) = (ledger.stash.clone(), ledger.total);
if let Some(current_era) = Self::current_era() {
ledger = ledger.consolidate_unlocked(current_era)
}

let post_info_weight = if ledger.unlocking.is_empty() &&
ledger.active < T::Currency::minimum_balance()
{
// This account must have called `unbond()` with some value that caused the active
// portion to fall below existential deposit + will have no more unlocking chunks
// left. We can now safely remove all staking-related information.
Self::kill_stash(&stash, num_slashing_spans)?;
// Remove the lock.
T::Currency::remove_lock(STAKING_ID, &stash);
// This is worst case scenario, so we use the full weight and return None
None
} else {
// This was the consequence of a partial unbond. just update the ledger and move on.
Self::update_ledger(&controller, &ledger);

// This is only an update, so we use less overall weight.
Some(T::WeightInfo::withdraw_unbonded_update(num_slashing_spans))
};

// `old_total` should never be less than the new total because
// `consolidate_unlocked` strictly subtracts balance.
if ledger.total < old_total {
// Already checked that this won't overflow by entry condition.
let value = old_total - ledger.total;
Self::deposit_event(Event::<T>::Withdrawn { stash, amount: value });
}

Ok(post_info_weight.into())
Self::do_withdraw_unbonded(&controller, num_slashing_spans)
.map(|weight| frame_support::dispatch::PostDispatchInfo {
gpestana marked this conversation as resolved.
Show resolved Hide resolved
actual_weight: Some(weight),
pays_fee: Pays::Yes,
})
.map_err(|e| e.into())
gpestana marked this conversation as resolved.
Show resolved Hide resolved
gpestana marked this conversation as resolved.
Show resolved Hide resolved
}

/// Declare the desire to validate for the origin controller.
Expand Down
60 changes: 46 additions & 14 deletions frame/staking/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,12 +1350,14 @@ fn bond_extra_and_withdraw_unbonded_works() {
}

#[test]
fn too_many_unbond_calls_should_not_work() {
fn many_unbond_calls_should_work() {
kianenigma marked this conversation as resolved.
Show resolved Hide resolved
ExtBuilder::default().build_and_execute(|| {
let mut current_era = 0;
// locked at era MaxUnlockingChunks - 1 until 3

for i in 0..<<Test as Config>::MaxUnlockingChunks as Get<u32>>::get() - 1 {
let max_unlocking_chunks = <<Test as Config>::MaxUnlockingChunks as Get<u32>>::get();

for i in 0..max_unlocking_chunks - 1 {
// There is only 1 chunk per era, so we need to be in a new era to create a chunk.
current_era = i as u32;
mock::start_active_era(current_era);
Expand All @@ -1365,27 +1367,57 @@ fn too_many_unbond_calls_should_not_work() {
current_era += 1;
mock::start_active_era(current_era);

// This chunk is locked at `current_era` through `current_era + 2` (because BondingDuration
// == 3).
// This chunk is locked at `current_era` through `current_era + 2` (because
// `BondingDuration` == 3).
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));
assert_eq!(
Staking::ledger(&10).unwrap().unlocking.len(),
Staking::ledger(&10).map(|l| l.unlocking.len()).unwrap(),
<<Test as Config>::MaxUnlockingChunks as Get<u32>>::get() as usize
);
// can't do more.
assert_noop!(Staking::unbond(RuntimeOrigin::signed(10), 1), Error::<Test>::NoMoreChunks);

current_era += 2;
// even though the number of unlocked chunks is the same as `MaxUnlockingChunks`,
// unbonding works as expected.
for i in current_era..(current_era + max_unlocking_chunks) - 1 {
// There is only 1 chunk per era, so we need to be in a new era to create a chunk.
current_era = i as u32;
mock::start_active_era(current_era);
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));
}

// only slots within last `BondingDuration` are filled.
assert_eq!(
Staking::ledger(&10).map(|l| l.unlocking.len()).unwrap(),
<<Test as Config>::BondingDuration>::get() as usize
);
})
}

#[test]
fn auto_withdraw_may_not_unlock_all_chunks() {
ExtBuilder::default().build_and_execute(|| {
// set `MaxUnlockingChunks` to a low number to test case when the unbonding period
// is larger than the number of unlocking chunks available, which may result on a
// `Error::NoMoreChunks`, even when the auto-withdraw tries to release locked chunks.
MaxUnlockingChunks::set(1);

let mut current_era = 0;

// fills the chunking slots for account
mock::start_active_era(current_era);
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));

assert_noop!(Staking::unbond(RuntimeOrigin::signed(10), 1), Error::<Test>::NoMoreChunks);
// free up everything except the most recently added chunk.
assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(10), 0));
assert_eq!(Staking::ledger(&10).unwrap().unlocking.len(), 1);
current_era += 1;
mock::start_active_era(current_era);

// unbonding will fail because i) there are no remaining chunks and ii) no filled chunks
// can be released because current chunk hasn't stay in the queue for at least
// `BondingDuration`
assert!(Staking::unbond(RuntimeOrigin::signed(10), 1).is_err());
gpestana marked this conversation as resolved.
Show resolved Hide resolved

// Can add again.
// fast-forward a few eras for unbond to be successful with implicit withdraw
current_era += 10;
mock::start_active_era(current_era);
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));
assert_eq!(Staking::ledger(&10).unwrap().unlocking.len(), 2);
})
}

Expand Down