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

Full Unbond in Staking #3811

Open
wants to merge 69 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
c6e4c93
force unbond in staking
dharjeezy Mar 9, 2024
12e62a9
check to see if controller wants to fully unbond
dharjeezy Mar 12, 2024
345b2fa
comment
dharjeezy Mar 12, 2024
0b8ef9e
unimplemented force_unbond
dharjeezy Mar 12, 2024
df5c186
full unbond instead
dharjeezy Mar 24, 2024
23e98b5
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Apr 9, 2024
66d9b61
implicit chill when unbonding
dharjeezy Apr 9, 2024
0292c5f
fmt
dharjeezy Apr 9, 2024
9db89f3
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Apr 10, 2024
4d449bf
no need for outright chill since unbond does chilling implicitly befo…
dharjeezy Apr 10, 2024
716723f
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Apr 18, 2024
a026b88
test, benchmark and pr doc
dharjeezy Apr 18, 2024
269934e
fmt
dharjeezy Apr 18, 2024
507a617
nit
dharjeezy Apr 20, 2024
9f3a8db
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Apr 24, 2024
a6731a6
fix test
dharjeezy May 1, 2024
e01c986
Merge branch 'master' into dami/full_unbond
dharjeezy May 1, 2024
62766c2
nit
dharjeezy May 1, 2024
91b5e8b
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy May 25, 2024
f1b87f9
nit
dharjeezy May 25, 2024
f92a40e
Merge branch 'master' into dami/full_unbond
dharjeezy Jun 29, 2024
6de3833
modifications, remove full_unbond impl
dharjeezy Jul 7, 2024
88f5c35
fix test
dharjeezy Jul 10, 2024
c1cc0cb
Merge branch 'master' into dami/full_unbond
dharjeezy Jul 10, 2024
7d28f72
fix test
dharjeezy Jul 11, 2024
0237e71
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Jul 17, 2024
215ae86
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Aug 11, 2024
52ad45f
corrections based on review
dharjeezy Aug 11, 2024
bf9680a
fmt
dharjeezy Aug 11, 2024
11d1ddf
Merge branch 'master' into dami/full_unbond
Ank4n Aug 12, 2024
96667b1
Merge branch 'master' into dami/full_unbond
Ank4n Aug 13, 2024
14212e8
".git/.scripts/commands/bench/bench.sh" --subcommand=pallet --runtime…
Aug 13, 2024
f19177f
nit
dharjeezy Aug 25, 2024
945f69d
Merge branch 'master' into dami/full_unbond
gpestana Aug 29, 2024
aca4c8f
add chill weight to unbond
dharjeezy Sep 14, 2024
36d8564
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Sep 14, 2024
38705e6
revert unsolicited changes
dharjeezy Sep 14, 2024
95e61d0
revert benchmarks according to Ankan
dharjeezy Sep 14, 2024
30015c7
Merge branch 'master' into dami/full_unbond
Ank4n Sep 16, 2024
4536363
revert
dharjeezy Sep 16, 2024
06e8563
fmt
dharjeezy Sep 16, 2024
2fc715a
code review changes
dharjeezy Oct 3, 2024
766458c
fmt
dharjeezy Oct 3, 2024
16f6535
Merge branch 'master' into dami/full_unbond
Ank4n Oct 4, 2024
280c3a1
Merge branch 'master' into dami/full_unbond
Ank4n Oct 8, 2024
bcc222d
Merge branch 'master' into dami/full_unbond
Ank4n Oct 24, 2024
9c62abc
nits
dharjeezy Oct 26, 2024
1ea562f
Merge branch 'master' into dami/full_unbond
dharjeezy Oct 26, 2024
30b23d1
nit
dharjeezy Oct 26, 2024
05f25f9
nit
dharjeezy Oct 26, 2024
d7e3cac
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Nov 23, 2024
c47d381
fix merge conflicts
dharjeezy Nov 23, 2024
67034ab
fmt
dharjeezy Nov 23, 2024
6c91ffc
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Jan 25, 2025
e65b728
merge conflict resolving
dharjeezy Jan 25, 2025
a187b0d
fmt
dharjeezy Jan 25, 2025
5e34df5
Merge branch 'master' into dami/full_unbond
Ank4n Jan 27, 2025
936b12f
nit conversation
dharjeezy Jan 27, 2025
03392ac
Merge remote-tracking branch 'origin/dami/full_unbond' into dami/full…
dharjeezy Jan 27, 2025
66ce2ed
assert to ensure Validators is not present in the count of all valida…
dharjeezy Jan 28, 2025
6f89fe4
Merge branch 'master' into dami/full_unbond
Ank4n Feb 6, 2025
d9eb3e5
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Feb 20, 2025
6c884cc
Merge remote-tracking branch 'origin/master' into dami/full_unbond
dharjeezy Feb 22, 2025
7b89287
fix test
dharjeezy Feb 22, 2025
21c0d3e
fmt
dharjeezy Feb 22, 2025
07901f4
Merge remote-tracking branch 'origin/dami/full_unbond' into dami/full…
dharjeezy Feb 22, 2025
16ebadd
fmt
dharjeezy Feb 22, 2025
adcf410
Merge branch 'master' into dami/full_unbond
Ank4n Mar 1, 2025
d2e016e
".git/.scripts/commands/fmt/fmt.sh"
Mar 1, 2025
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
4 changes: 4 additions & 0 deletions polkadot/runtime/westend/src/weights/pallet_staking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,4 +826,8 @@ impl<T: frame_system::Config> pallet_staking::WeightInfo for WeightInfo<T> {
.saturating_add(T::DbWeight::get().reads(5))
.saturating_add(T::DbWeight::get().writes(4))
}

fn full_unbond() -> Weight {
todo!()
}
}
10 changes: 10 additions & 0 deletions prdoc/pr_3811.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
title: "[pallet_staking] Staking `full_unbond`"

doc:
- audience: Runtime Dev
description: |
Full unbond when staking such that to fully unbond as a validator or nominator, chilled occurs first
before unbonding

crates:
- name: pallet-staking
8 changes: 0 additions & 8 deletions substrate/frame/nomination-pools/test-staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,6 @@ fn pool_lifecycle_e2e() {
]
);

// as soon as all members have left, the depositor can try to unbond, but since the
// min-nominator intention is set, they must chill first.
assert_noop!(
Pools::unbond(RuntimeOrigin::signed(10), 10, 50),
pallet_staking::Error::<Runtime>::InsufficientBond
);

assert_ok!(Pools::chill(RuntimeOrigin::signed(10), 1));
assert_ok!(Pools::unbond(RuntimeOrigin::signed(10), 10, 50));

assert_eq!(
Expand Down
26 changes: 26 additions & 0 deletions substrate/frame/staking/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,32 @@ benchmarks! {
assert_eq!(Staking::<T>::inspect_bond_state(&stash), Ok(LedgerIntegrityState::Ok));
}

full_unbond {
// clean up any existing state.
clear_validators_and_nominators::<T>();

// setup the worst case list scenario.
let total_issuance = T::Currency::total_issuance();
// the weight the nominator will start at. The value used here is expected to be
// significantly higher than the first position in a list (e.g. the first bag threshold).
let origin_weight = BalanceOf::<T>::try_from(952_994_955_240_703u128)
.map_err(|_| "balance expected to be a u128")
.unwrap();
let scenario = ListScenario::<T>::new(origin_weight, false)?;

let stash = scenario.origin_stash1.clone();
let controller = scenario.origin_controller1.clone();
let ledger = Ledger::<T>::get(&controller).ok_or("ledger not created before")?;
let original_bonded: BalanceOf<T> = ledger.active;

whitelist_account!(controller);
}: _(RawOrigin::Signed(controller.clone()))
verify {
let ledger = Ledger::<T>::get(&controller).ok_or("ledger not created after")?;
let new_bonded: BalanceOf<T> = ledger.active;
assert!(original_bonded > new_bonded);
}

impl_benchmark_test_suite!(
Staking,
crate::mock::ExtBuilder::default().has_stakers(true),
Expand Down
83 changes: 82 additions & 1 deletion substrate/frame/staking/src/pallet/impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ use crate::{
election_size_tracker::StaticTracker, log, slashing, weights::WeightInfo, ActiveEraInfo,
BalanceOf, EraInfo, EraPayout, Exposure, ExposureOf, Forcing, IndividualExposure,
LedgerIntegrityState, MaxNominationsOf, MaxWinnersOf, Nominations, NominationsQuota,
PositiveImbalanceOf, RewardDestination, SessionInterface, StakingLedger, ValidatorPrefs,
PositiveImbalanceOf, RewardDestination, SessionInterface, StakingLedger, UnlockChunk,
ValidatorPrefs,
};

use super::pallet::*;
Expand Down Expand Up @@ -1132,6 +1133,86 @@ impl<T: Config> Pallet<T> {
) -> Exposure<T::AccountId, BalanceOf<T>> {
EraInfo::<T>::get_full_exposure(era, account)
}

/// Unbonds a controller.
pub(crate) fn do_unbond(
controller: T::AccountId,
value: BalanceOf<T>,
) -> Result<Option<Weight>, DispatchError> {
let unlocking = Self::ledger(Controller(controller.clone())).map(|l| l.unlocking.len())?;

// 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).map_or(0, |s| s.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(controller.clone()))?;
let mut value = value.min(ledger.active);
let stash = ledger.stash.clone();

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

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

// Avoid there being a dust balance left in the staking system.
if ledger.active < T::Currency::minimum_balance() {
value += ledger.active;
ledger.active = Zero::zero();
}

let min_active_bond = if Nominators::<T>::contains_key(&stash) {
MinNominatorBond::<T>::get()
} else if Validators::<T>::contains_key(&stash) {
MinValidatorBond::<T>::get()
} else {
Zero::zero()
};

// Make sure that the user maintains enough active bond for their role.
// If a user runs into this error, they should chill first.
ensure!(ledger.active >= min_active_bond, Error::<T>::InsufficientBond);

// Note: in case there is no current era it is fine to bond one era more.
let era = Self::current_era()
.unwrap_or(0)
.defensive_saturating_add(T::BondingDuration::get());
if let Some(chunk) = ledger.unlocking.last_mut().filter(|chunk| chunk.era == era) {
// To keep the chunk count down, we only keep one chunk per era. Since
// `unlocking` is a FiFo queue, if a chunk exists for `era` we know that it will
// be the last one.
chunk.value = chunk.value.defensive_saturating_add(value)
} else {
ledger
.unlocking
.try_push(UnlockChunk { value, era })
.map_err(|_| Error::<T>::NoMoreChunks)?;
};
// NOTE: ledger must be updated prior to calling `Self::weight_of`.
ledger.update()?;

// update this staker in the sorted list, if they exist in it.
if T::VoterList::contains(&stash) {
let _ = T::VoterList::on_update(&stash, Self::weight_of(&stash)).defensive();
}

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

Ok(maybe_withdraw_weight)
}
}

impl<T: Config> Pallet<T> {
Expand Down
101 changes: 30 additions & 71 deletions substrate/frame/staking/src/pallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ use frame_election_provider_support::{
use frame_support::{
pallet_prelude::*,
traits::{
Currency, Defensive, DefensiveSaturating, EnsureOrigin, EstimateNextNewSession, Get,
InspectLockableCurrency, LockableCurrency, OnUnbalanced, UnixTime, WithdrawReasons,
Currency, Defensive, EnsureOrigin, EstimateNextNewSession, Get, InspectLockableCurrency,
LockableCurrency, OnUnbalanced, UnixTime, WithdrawReasons,
},
weights::Weight,
BoundedVec,
Expand Down Expand Up @@ -1038,79 +1038,15 @@ pub mod pallet {
#[pallet::compact] value: BalanceOf<T>,
) -> DispatchResultWithPostInfo {
let controller = ensure_signed(origin)?;
let unlocking =
Copy link
Contributor

Choose a reason for hiding this comment

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

github doesn't allow me to add comment on the non changed line 1090 so adding here.

Add chill weight to the call so in worst case it always adds chill weight.

		#[pallet::call_index(2)]
		#[pallet::weight(
            T::WeightInfo::withdraw_unbonded_kill(SPECULATIVE_NUM_SPANS).saturating_add(T::WeightInfo::unbond())
			.saturating_add(T::WeightInfo::Chill()))
        ]

Additional info: Before any transaction this fund is reserved, and any difference in the PostInfo is refunded. So this should always contain the worst case weight.

Self::ledger(Controller(controller.clone())).map(|l| l.unlocking.len())?;

// 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).map_or(0, |s| s.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(controller))?;
let mut value = value.min(ledger.active);
let stash = ledger.stash.clone();

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

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

// Avoid there being a dust balance left in the staking system.
if ledger.active < T::Currency::minimum_balance() {
value += ledger.active;
ledger.active = Zero::zero();
}

let min_active_bond = if Nominators::<T>::contains_key(&stash) {
MinNominatorBond::<T>::get()
} else if Validators::<T>::contains_key(&stash) {
MinValidatorBond::<T>::get()
} else {
Zero::zero()
};

// Make sure that the user maintains enough active bond for their role.
// If a user runs into this error, they should chill first.
ensure!(ledger.active >= min_active_bond, Error::<T>::InsufficientBond);

// Note: in case there is no current era it is fine to bond one era more.
let era = Self::current_era()
.unwrap_or(0)
.defensive_saturating_add(T::BondingDuration::get());
if let Some(chunk) = ledger.unlocking.last_mut().filter(|chunk| chunk.era == era) {
// To keep the chunk count down, we only keep one chunk per era. Since
// `unlocking` is a FiFo queue, if a chunk exists for `era` we know that it will
// be the last one.
chunk.value = chunk.value.defensive_saturating_add(value)
} else {
ledger
.unlocking
.try_push(UnlockChunk { value, era })
.map_err(|_| Error::<T>::NoMoreChunks)?;
};
// NOTE: ledger must be updated prior to calling `Self::weight_of`.
ledger.update()?;

// update this staker in the sorted list, if they exist in it.
if T::VoterList::contains(&stash) {
let _ = T::VoterList::on_update(&stash, Self::weight_of(&stash)).defensive();
}
let ledger = Self::ledger(StakingAccount::Controller(controller.clone()))?;

Self::deposit_event(Event::<T>::Unbonded { stash, amount: value });
if value == ledger.total {
Copy link
Contributor

Choose a reason for hiding this comment

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

Because of this, you may want to check value >= ledger.total instead.

Alternatively, you can move the ceiling logic let mut value = value.min(ledger.active) to this function.

Self::chill_stash(&ledger.stash);
}

let maybe_withdraw_weight = Self::do_unbond(controller, value)?;

let actual_weight = if let Some(withdraw_weight) = maybe_withdraw_weight {
Some(T::WeightInfo::unbond().saturating_add(withdraw_weight))
} else {
Expand Down Expand Up @@ -2085,6 +2021,29 @@ pub mod pallet {
);
Ok(())
}

/// Fully Unbonds by Chilling first
/// Emits `Unbonded`.
#[pallet::call_index(30)]
#[pallet::weight(
T::WeightInfo::withdraw_unbonded_kill(SPECULATIVE_NUM_SPANS).saturating_add(T::WeightInfo::full_unbond()))
]
pub fn full_unbond(origin: OriginFor<T>) -> DispatchResultWithPostInfo {
let controller = ensure_signed(origin)?;

let ledger = Self::ledger(StakingAccount::Controller(controller.clone()))?;

Self::chill_stash(&ledger.stash);

let maybe_withdraw_weight = Self::do_unbond(controller, ledger.active)?;
let actual_weight = if let Some(withdraw_weight) = maybe_withdraw_weight {
Some(T::WeightInfo::full_unbond().saturating_add(withdraw_weight))
} else {
Some(T::WeightInfo::full_unbond())
};

Ok(actual_weight.into())
}
}
}

Expand Down
37 changes: 37 additions & 0 deletions substrate/frame/staking/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4069,6 +4069,43 @@ fn test_multi_page_payout_stakers_by_page() {
});
}

#[test]
fn full_unbond_works() {
//
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
//

// * Should test
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
// * Should test
// Should test:

// * Given an account being bonded [and chosen as a validator](not mandatory)
// * it can force unbond a portion of its funds from the stash account.
ExtBuilder::default().nominate(false).build_and_execute(|| {
// Set payee to stash.
assert_ok!(Staking::set_payee(RuntimeOrigin::signed(11), RewardDestination::Stash));

// Give account 11 some large free balance greater than total
let _ = Balances::make_free_balance_be(&11, 1000000);

// confirm that 10 is a normal validator and gets paid at the end of the era.
mock::start_active_era(1);

// Initial state of 11
assert_eq!(
Staking::ledger(11.into()).unwrap(),
StakingLedgerInspect {
stash: 11,
total: 1000,
active: 1000,
unlocking: Default::default(),
legacy_claimed_rewards: bounded_vec![],
}
);

mock::start_active_era(2);
assert_eq!(active_era(), 2);

// Force Unbond all of the funds in stash which makes the call to chill first.
let res = Staking::full_unbond(RuntimeOrigin::signed(11));
assert!(res.is_ok());
})
Copy link
Contributor

Choose a reason for hiding this comment

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

One last check is to ensure that the stash is actually chilled instead of only relying on the events above. e.g.

assert!(Nominators::<Test>::get(11).is_none());
assert!(Validators::<Test>::get(11).is_none());

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 have done this

Copy link
Contributor

@tdimitrov tdimitrov Jan 28, 2025

Choose a reason for hiding this comment

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

Probably I am missing something but I don't see the asserts?

EDIT: in case github misplaces the comment, I am referring to @gpestana's comment here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, so what i did is to do this check

assert!(!Validators::<Test>::contains_key(11));
This ensures the Validators does not contain 11 after the event.

I have also included checking the total count of the validators before and after unbonding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}

#[test]
fn test_multi_page_payout_stakers_backward_compatible() {
// Test that payout_stakers work in general and that it pays the correct amount of reward.
Expand Down
39 changes: 39 additions & 0 deletions substrate/frame/staking/src/weights.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading