Skip to content

Commit f763ff6

Browse files
gpestanakianenigma
andauthored
Automatic withdraw_unbonded upon unbond (paritytech#12582)
* 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 <5588131+kianenigma@users.noreply.github.com> * Update frame/staking/src/pallet/mod.rs Co-authored-by: Kian Paimani <5588131+kianenigma@users.noreply.github.com> * 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 <5588131+kianenigma@users.noreply.github.com> Co-authored-by: command-bot <>
1 parent cd2fdcf commit f763ff6

File tree

6 files changed

+321
-238
lines changed

6 files changed

+321
-238
lines changed

frame/nomination-pools/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,9 +1644,12 @@ pub mod pallet {
16441644
/// # Note
16451645
///
16461646
/// If there are too many unlocking chunks to unbond with the pool account,
1647-
/// [`Call::pool_withdraw_unbonded`] can be called to try and minimize unlocking chunks. If
1648-
/// there are too many unlocking chunks, the result of this call will likely be the
1649-
/// `NoMoreChunks` error from the staking system.
1647+
/// [`Call::pool_withdraw_unbonded`] can be called to try and minimize unlocking chunks.
1648+
/// The [`StakingInterface::unbond`] will implicitly call [`Call::pool_withdraw_unbonded`]
1649+
/// to try to free chunks if necessary (ie. if unbound was called and no unlocking chunks
1650+
/// are available). However, it may not be possible to release the current unlocking chunks,
1651+
/// in which case, the result of this call will likely be the `NoMoreChunks` error from the
1652+
/// staking system.
16501653
#[pallet::call_index(3)]
16511654
#[pallet::weight(T::WeightInfo::unbond())]
16521655
pub fn unbond(

frame/staking/src/benchmarking.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ benchmarks! {
316316
let scenario = ListScenario::<T>::new(origin_weight, true)?;
317317
let controller = scenario.origin_controller1.clone();
318318
let stash = scenario.origin_stash1;
319+
add_slashing_spans::<T>(&stash, s);
319320
assert!(T::VoterList::contains(&stash));
320321

321322
let ed = T::Currency::minimum_balance();

frame/staking/src/pallet/impls.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,45 @@ impl<T: Config> Pallet<T> {
9292
Self::slashable_balance_of_vote_weight(who, issuance)
9393
}
9494

95+
pub(super) fn do_withdraw_unbonded(
96+
controller: &T::AccountId,
97+
num_slashing_spans: u32,
98+
) -> Result<Weight, DispatchError> {
99+
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
100+
let (stash, old_total) = (ledger.stash.clone(), ledger.total);
101+
if let Some(current_era) = Self::current_era() {
102+
ledger = ledger.consolidate_unlocked(current_era)
103+
}
104+
105+
let used_weight =
106+
if ledger.unlocking.is_empty() && ledger.active < T::Currency::minimum_balance() {
107+
// This account must have called `unbond()` with some value that caused the active
108+
// portion to fall below existential deposit + will have no more unlocking chunks
109+
// left. We can now safely remove all staking-related information.
110+
Self::kill_stash(&stash, num_slashing_spans)?;
111+
// Remove the lock.
112+
T::Currency::remove_lock(STAKING_ID, &stash);
113+
114+
T::WeightInfo::withdraw_unbonded_kill(num_slashing_spans)
115+
} else {
116+
// This was the consequence of a partial unbond. just update the ledger and move on.
117+
Self::update_ledger(&controller, &ledger);
118+
119+
// This is only an update, so we use less overall weight.
120+
T::WeightInfo::withdraw_unbonded_update(num_slashing_spans)
121+
};
122+
123+
// `old_total` should never be less than the new total because
124+
// `consolidate_unlocked` strictly subtracts balance.
125+
if ledger.total < old_total {
126+
// Already checked that this won't overflow by entry condition.
127+
let value = old_total - ledger.total;
128+
Self::deposit_event(Event::<T>::Withdrawn { stash, amount: value });
129+
}
130+
131+
Ok(used_weight)
132+
}
133+
95134
pub(super) fn do_payout_stakers(
96135
validator_stash: T::AccountId,
97136
era: EraIndex,
@@ -1568,6 +1607,8 @@ impl<T: Config> StakingInterface for Pallet<T> {
15681607
fn unbond(who: &Self::AccountId, value: Self::Balance) -> DispatchResult {
15691608
let ctrl = Self::bonded(who).ok_or(Error::<T>::NotStash)?;
15701609
Self::unbond(RawOrigin::Signed(ctrl).into(), value)
1610+
.map_err(|with_post| with_post.error)
1611+
.map(|_| ())
15711612
}
15721613

15731614
fn chill(who: &Self::AccountId) -> DispatchResult {

frame/staking/src/pallet/mod.rs

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ use crate::{
5151
};
5252

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

5559
#[frame_support::pallet]
5660
pub mod pallet {
@@ -115,7 +119,6 @@ pub mod pallet {
115119
// we only accept an election provider that has staking as data provider.
116120
DataProvider = Pallet<Self>,
117121
>;
118-
119122
/// Something that provides the election functionality at genesis.
120123
type GenesisElectionProvider: ElectionProvider<
121124
AccountId = Self::AccountId,
@@ -953,8 +956,8 @@ pub mod pallet {
953956
/// the funds out of management ready for transfer.
954957
///
955958
/// No more than a limited number of unlocking chunks (see `MaxUnlockingChunks`)
956-
/// can co-exists at the same time. In that case, [`Call::withdraw_unbonded`] need
957-
/// to be called first to remove some of the chunks (if possible).
959+
/// can co-exists at the same time. If there are no unlocking chunks slots available
960+
/// [`Call::withdraw_unbonded`] is called to remove some of the chunks (if possible).
958961
///
959962
/// If a user encounters the `InsufficientBond` error when calling this extrinsic,
960963
/// they should call `chill` first in order to free up their bonded funds.
@@ -963,20 +966,39 @@ pub mod pallet {
963966
///
964967
/// See also [`Call::withdraw_unbonded`].
965968
#[pallet::call_index(2)]
966-
#[pallet::weight(T::WeightInfo::unbond())]
969+
#[pallet::weight(
970+
T::WeightInfo::withdraw_unbonded_kill(SPECULATIVE_NUM_SPANS).saturating_add(T::WeightInfo::unbond()))
971+
]
967972
pub fn unbond(
968973
origin: OriginFor<T>,
969974
#[pallet::compact] value: BalanceOf<T>,
970-
) -> DispatchResult {
975+
) -> DispatchResultWithPostInfo {
971976
let controller = ensure_signed(origin)?;
977+
let unlocking = Self::ledger(&controller)
978+
.map(|l| l.unlocking.len())
979+
.ok_or(Error::<T>::NotController)?;
980+
981+
// if there are no unlocking chunks available, try to withdraw chunks older than
982+
// `BondingDuration` to proceed with the unbonding.
983+
let maybe_withdraw_weight = {
984+
if unlocking == T::MaxUnlockingChunks::get() as usize {
985+
let real_num_slashing_spans = Self::slashing_spans(&controller).iter().count();
986+
Some(Self::do_withdraw_unbonded(&controller, real_num_slashing_spans as u32)?)
987+
} else {
988+
None
989+
}
990+
};
991+
992+
// we need to fetch the ledger again because it may have been mutated in the call
993+
// to `Self::do_withdraw_unbonded` above.
972994
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
995+
let mut value = value.min(ledger.active);
996+
973997
ensure!(
974998
ledger.unlocking.len() < T::MaxUnlockingChunks::get() as usize,
975999
Error::<T>::NoMoreChunks,
9761000
);
9771001

978-
let mut value = value.min(ledger.active);
979-
9801002
if !value.is_zero() {
9811003
ledger.active -= value;
9821004

@@ -1024,7 +1046,14 @@ pub mod pallet {
10241046

10251047
Self::deposit_event(Event::<T>::Unbonded { stash: ledger.stash, amount: value });
10261048
}
1027-
Ok(())
1049+
1050+
let actual_weight = if let Some(withdraw_weight) = maybe_withdraw_weight {
1051+
Some(T::WeightInfo::unbond().saturating_add(withdraw_weight))
1052+
} else {
1053+
Some(T::WeightInfo::unbond())
1054+
};
1055+
1056+
Ok(actual_weight.into())
10281057
}
10291058

10301059
/// Remove any unlocked chunks from the `unlocking` queue from our management.
@@ -1049,40 +1078,9 @@ pub mod pallet {
10491078
num_slashing_spans: u32,
10501079
) -> DispatchResultWithPostInfo {
10511080
let controller = ensure_signed(origin)?;
1052-
let mut ledger = Self::ledger(&controller).ok_or(Error::<T>::NotController)?;
1053-
let (stash, old_total) = (ledger.stash.clone(), ledger.total);
1054-
if let Some(current_era) = Self::current_era() {
1055-
ledger = ledger.consolidate_unlocked(current_era)
1056-
}
1057-
1058-
let post_info_weight = if ledger.unlocking.is_empty() &&
1059-
ledger.active < T::Currency::minimum_balance()
1060-
{
1061-
// This account must have called `unbond()` with some value that caused the active
1062-
// portion to fall below existential deposit + will have no more unlocking chunks
1063-
// left. We can now safely remove all staking-related information.
1064-
Self::kill_stash(&stash, num_slashing_spans)?;
1065-
// Remove the lock.
1066-
T::Currency::remove_lock(STAKING_ID, &stash);
1067-
// This is worst case scenario, so we use the full weight and return None
1068-
None
1069-
} else {
1070-
// This was the consequence of a partial unbond. just update the ledger and move on.
1071-
Self::update_ledger(&controller, &ledger);
1072-
1073-
// This is only an update, so we use less overall weight.
1074-
Some(T::WeightInfo::withdraw_unbonded_update(num_slashing_spans))
1075-
};
1076-
1077-
// `old_total` should never be less than the new total because
1078-
// `consolidate_unlocked` strictly subtracts balance.
1079-
if ledger.total < old_total {
1080-
// Already checked that this won't overflow by entry condition.
1081-
let value = old_total - ledger.total;
1082-
Self::deposit_event(Event::<T>::Withdrawn { stash, amount: value });
1083-
}
10841081

1085-
Ok(post_info_weight.into())
1082+
let actual_weight = Self::do_withdraw_unbonded(&controller, num_slashing_spans)?;
1083+
Ok(Some(actual_weight).into())
10861084
}
10871085

10881086
/// Declare the desire to validate for the origin controller.

frame/staking/src/tests.rs

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,12 +1350,14 @@ fn bond_extra_and_withdraw_unbonded_works() {
13501350
}
13511351

13521352
#[test]
1353-
fn too_many_unbond_calls_should_not_work() {
1353+
fn many_unbond_calls_should_work() {
13541354
ExtBuilder::default().build_and_execute(|| {
13551355
let mut current_era = 0;
13561356
// locked at era MaxUnlockingChunks - 1 until 3
13571357

1358-
for i in 0..<<Test as Config>::MaxUnlockingChunks as Get<u32>>::get() - 1 {
1358+
let max_unlocking_chunks = <<Test as Config>::MaxUnlockingChunks as Get<u32>>::get();
1359+
1360+
for i in 0..max_unlocking_chunks - 1 {
13591361
// There is only 1 chunk per era, so we need to be in a new era to create a chunk.
13601362
current_era = i as u32;
13611363
mock::start_active_era(current_era);
@@ -1365,27 +1367,57 @@ fn too_many_unbond_calls_should_not_work() {
13651367
current_era += 1;
13661368
mock::start_active_era(current_era);
13671369

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

1378-
current_era += 2;
1378+
// even though the number of unlocked chunks is the same as `MaxUnlockingChunks`,
1379+
// unbonding works as expected.
1380+
for i in current_era..(current_era + max_unlocking_chunks) - 1 {
1381+
// There is only 1 chunk per era, so we need to be in a new era to create a chunk.
1382+
current_era = i as u32;
1383+
mock::start_active_era(current_era);
1384+
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));
1385+
}
1386+
1387+
// only slots within last `BondingDuration` are filled.
1388+
assert_eq!(
1389+
Staking::ledger(&10).map(|l| l.unlocking.len()).unwrap(),
1390+
<<Test as Config>::BondingDuration>::get() as usize
1391+
);
1392+
})
1393+
}
1394+
1395+
#[test]
1396+
fn auto_withdraw_may_not_unlock_all_chunks() {
1397+
ExtBuilder::default().build_and_execute(|| {
1398+
// set `MaxUnlockingChunks` to a low number to test case when the unbonding period
1399+
// is larger than the number of unlocking chunks available, which may result on a
1400+
// `Error::NoMoreChunks`, even when the auto-withdraw tries to release locked chunks.
1401+
MaxUnlockingChunks::set(1);
1402+
1403+
let mut current_era = 0;
1404+
1405+
// fills the chunking slots for account
1406+
mock::start_active_era(current_era);
1407+
assert_ok!(Staking::unbond(RuntimeOrigin::signed(10), 1));
1408+
1409+
current_era += 1;
13791410
mock::start_active_era(current_era);
13801411

1412+
// unbonding will fail because i) there are no remaining chunks and ii) no filled chunks
1413+
// can be released because current chunk hasn't stay in the queue for at least
1414+
// `BondingDuration`
13811415
assert_noop!(Staking::unbond(RuntimeOrigin::signed(10), 1), Error::<Test>::NoMoreChunks);
1382-
// free up everything except the most recently added chunk.
1383-
assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(10), 0));
1384-
assert_eq!(Staking::ledger(&10).unwrap().unlocking.len(), 1);
13851416

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

0 commit comments

Comments
 (0)