Skip to content

Commit

Permalink
Automatic withdraw_unbonded upon unbond (paritytech#12582)
Browse files Browse the repository at this point in the history
* Prevents max unbonding chunk slots from being filled when unbonding in the staking pallet

* hardcode num_slashing to unlock chunks automatically

* refactor withdraw logic to do_withdraw; idiomatic rust improvements

* a

* callable unbond() to return a DispatchWithPostInfo to dynamically update the consumed weight

* refunds overpaid fees when unbond with withdraw

* fetches real slashing spans before withdrawal call

* nits

* addresses PR comments

* Adds more testing

* fixes doc comments

* Fixes weight refunding logic for fn unbond

* generalizes  to return used weight or dispatch error

* Update frame/staking/src/pallet/mod.rs

Co-authored-by: Kian Paimani <[email protected]>

* Update frame/staking/src/pallet/mod.rs

Co-authored-by: Kian Paimani <[email protected]>

* Addresses PR comments

* Add comment to speculative num spans

* adds missing add_slashing_spans in withdraw_unbonded_kill benchmarks

* ".git/.scripts/bench-bot.sh" pallet dev pallet_staking

* fix publish

Co-authored-by: Kian Paimani <[email protected]>
Co-authored-by: command-bot <>
  • Loading branch information
2 people authored and ark0f committed Feb 27, 2023
1 parent 7f7ca06 commit 544cf94
Show file tree
Hide file tree
Showing 6 changed files with 321 additions and 238 deletions.
9 changes: 6 additions & 3 deletions frame/nomination-pools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1644,9 +1644,12 @@ 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`]
/// 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::call_index(3)]
#[pallet::weight(T::WeightInfo::unbond())]
pub fn unbond(
Expand Down
1 change: 1 addition & 0 deletions frame/staking/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ benchmarks! {
let scenario = ListScenario::<T>::new(origin_weight, true)?;
let controller = scenario.origin_controller1.clone();
let stash = scenario.origin_stash1;
add_slashing_spans::<T>(&stash, s);
assert!(T::VoterList::contains(&stash));

let ed = T::Currency::minimum_balance();
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 @@ -1568,6 +1607,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
80 changes: 39 additions & 41 deletions frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ use crate::{
};

const STAKING_ID: LockIdentifier = *b"staking ";
// The speculative number of spans are used as an input of the weight annotation of
// [`Call::unbond`], as the post dipatch weight may depend on the number of slashing span on the
// account which is not provided as an input. The value set should be conservative but sensible.
pub(crate) const SPECULATIVE_NUM_SPANS: u32 = 32;

#[frame_support::pallet]
pub mod pallet {
Expand Down Expand Up @@ -115,7 +119,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: ElectionProvider<
AccountId = Self::AccountId,
Expand Down Expand Up @@ -953,8 +956,8 @@ 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.
Expand All @@ -963,20 +966,39 @@ pub mod pallet {
///
/// See also [`Call::withdraw_unbonded`].
#[pallet::call_index(2)]
#[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)?;

// if there are no unlocking chunks available, try to withdraw chunks older than
// `BondingDuration` to proceed with the unbonding.
let maybe_withdraw_weight = {
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
}
};

// 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)?;
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 @@ -1024,7 +1046,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(actual_weight.into())
}

/// Remove any unlocked chunks from the `unlocking` queue from our management.
Expand All @@ -1049,40 +1078,9 @@ 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())
let actual_weight = Self::do_withdraw_unbonded(&controller, num_slashing_spans)?;
Ok(Some(actual_weight).into())
}

/// Declare the desire to validate for the origin controller.
Expand Down
58 changes: 45 additions & 13 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() {
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));

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_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);

// 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
Loading

0 comments on commit 544cf94

Please sign in to comment.