Skip to content

Commit af905f1

Browse files
[staking] Currency Migration and Overstake fix (#7763)
## Summary The existing fungible migration code has an issue when handling partially unbonding accounts, leaving them in an inconsistent state. These changes fix it by properly withdrawing overstake from unlock chunks. This PR also removes the `withdraw_overstake` extrinsic from pallet-staking, as this scenario could only occur before the fungible migration. With fungibles, over-staking is no longer possible. ## TODO - [ ] Backport to stable2503. --------- Co-authored-by: cmd[bot] <41898282+github-actions[bot]@users.noreply.github.com> (cherry picked from commit f540d2b)
1 parent ee04fec commit af905f1

6 files changed

Lines changed: 189 additions & 122 deletions

File tree

polkadot/runtime/westend/src/tests.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ fn check_treasury_pallet_id() {
109109
#[cfg(all(test, feature = "try-runtime"))]
110110
mod remote_tests {
111111
use super::*;
112+
use frame_support::traits::{TryState, TryStateSelect::All};
112113
use frame_try_runtime::{runtime_decl_for_try_runtime::TryRuntime, UpgradeCheckSelect};
113114
use remote_externalities::{
114115
Builder, Mode, OfflineConfig, OnlineConfig, SnapshotConfig, Transport,
@@ -242,6 +243,10 @@ mod remote_tests {
242243
unexpected_errors
243244
);
244245
});
246+
247+
ext.execute_with(|| {
248+
AllPalletsWithSystem::try_state(System::block_number(), All).unwrap();
249+
});
245250
}
246251

247252
#[tokio::test]
@@ -313,6 +318,10 @@ mod remote_tests {
313318
force_withdraw_acc
314319
);
315320
});
321+
322+
ext.execute_with(|| {
323+
AllPalletsWithSystem::try_state(System::block_number(), All).unwrap();
324+
});
316325
}
317326
}
318327

prdoc/pr_7763.prdoc

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
title: '[staking] Currency Migration and Overstake fix'
2+
doc:
3+
- audience: Runtime Dev
4+
description: |-
5+
- Fixes Currency to fungible migration with force-unstake of partial unbonding accounts.
6+
- Removes the extrinsic `withdraw_overstake` which is not useful post fungibe migration of pallet-staking.
7+
8+
crates:
9+
- name: westend-runtime
10+
bump: major
11+
- name: pallet-staking
12+
bump: major

substrate/frame/staking/src/lib.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,52 @@ impl<T: Config> StakingLedger<T> {
632632
}
633633
}
634634

635+
/// Sets ledger total to the `new_total`.
636+
///
637+
/// Removes entries from `unlocking` upto `amount` starting from the oldest first.
638+
fn update_total_stake(mut self, new_total: BalanceOf<T>) -> Self {
639+
let old_total = self.total;
640+
self.total = new_total;
641+
debug_assert!(
642+
new_total <= old_total,
643+
"new_total {:?} must be <= old_total {:?}",
644+
new_total,
645+
old_total
646+
);
647+
648+
let to_withdraw = old_total.defensive_saturating_sub(new_total);
649+
// accumulator to keep track of how much is withdrawn.
650+
// First we take out from active.
651+
let mut withdrawn = BalanceOf::<T>::zero();
652+
653+
// first we try to remove stake from active
654+
if self.active >= to_withdraw {
655+
self.active -= to_withdraw;
656+
return self
657+
} else {
658+
withdrawn += self.active;
659+
self.active = BalanceOf::<T>::zero();
660+
}
661+
662+
// start removing from the oldest chunk.
663+
while let Some(last) = self.unlocking.last_mut() {
664+
if withdrawn.defensive_saturating_add(last.value) <= to_withdraw {
665+
withdrawn += last.value;
666+
self.unlocking.pop();
667+
} else {
668+
let diff = to_withdraw.defensive_saturating_sub(withdrawn);
669+
withdrawn += diff;
670+
last.value -= diff;
671+
}
672+
673+
if withdrawn >= to_withdraw {
674+
break
675+
}
676+
}
677+
678+
self
679+
}
680+
635681
/// Re-bond funds that were scheduled for unlocking.
636682
///
637683
/// Returns the updated ledger, and the amount actually rebonded.

substrate/frame/staking/src/pallet/impls.rs

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,18 +1352,20 @@ impl<T: Config> Pallet<T> {
13521352
} else {
13531353
// if we are here, it means we cannot hold all user stake. We will do a force withdraw
13541354
// from ledger, but that's okay since anyways user do not have funds for it.
1355-
let force_withdraw = staked.saturating_sub(max_hold);
1356-
1357-
// we ignore if active is 0. It implies the locked amount is not actively staked. The
1358-
// account can still get away from potential slash but we can't do much better here.
1359-
StakingLedger {
1360-
total: max_hold,
1361-
active: ledger.active.saturating_sub(force_withdraw),
1362-
// we are not changing the stash, so we can keep the stash.
1363-
..ledger
1364-
}
1365-
.update()?;
1366-
force_withdraw
1355+
1356+
let old_total = ledger.total;
1357+
// update ledger with total stake as max_hold.
1358+
let updated_ledger = ledger.update_total_stake(max_hold);
1359+
1360+
// new total stake in ledger.
1361+
let new_total = updated_ledger.total;
1362+
debug_assert_eq!(new_total, max_hold);
1363+
1364+
// update ledger in storage.
1365+
updated_ledger.update()?;
1366+
1367+
// return the diff
1368+
old_total.defensive_saturating_sub(new_total)
13671369
};
13681370

13691371
// Get rid of the extra consumer we used to have with OldCurrency.
@@ -1373,20 +1375,31 @@ impl<T: Config> Pallet<T> {
13731375
Ok(())
13741376
}
13751377

1378+
// These are system accounts and don’t normally hold funds, so migration isn’t strictly
1379+
// necessary. However, this is a good opportunity to clean up the extra consumer/providers that
1380+
// were previously used.
13761381
fn do_migrate_virtual_staker(stash: &T::AccountId) -> DispatchResult {
1377-
// Funds for virtual stakers not managed/held by this pallet. We only need to clear
1378-
// the extra consumer we used to have with OldCurrency.
1379-
frame_system::Pallet::<T>::dec_consumers(&stash);
1382+
let consumer_count = frame_system::Pallet::<T>::consumers(stash);
1383+
// fail early if no consumers.
1384+
ensure!(consumer_count > 0, Error::<T>::AlreadyMigrated);
1385+
1386+
// provider/consumer ref count has been a mess (inconsistent), and some of these accounts
1387+
// accumulated upto 2 consumers. But if it's more than 2, we simply fail to not allow
1388+
// this migration to be called multiple times.
1389+
ensure!(consumer_count <= 2, Error::<T>::BadState);
1390+
1391+
// get rid of the consumers
1392+
for _ in 0..consumer_count {
1393+
frame_system::Pallet::<T>::dec_consumers(&stash);
1394+
}
13801395

1381-
// The delegation system that manages the virtual staker needed to increment provider
1382-
// previously because of the consumer needed by this pallet. In reality, this stash
1383-
// is just a key for managing the ledger and the account does not need to hold any
1384-
// balance or exist. We decrement this provider.
1396+
// get the current count of providers
13851397
let actual_providers = frame_system::Pallet::<T>::providers(stash);
13861398

13871399
let expected_providers =
1388-
// provider is expected to be 1 but someone can always transfer some free funds to
1389-
// these accounts, increasing the provider.
1400+
// We expect these accounts to have only one provider, and hold no balance. However, if
1401+
// someone mischievously sends some funds to these accounts, they may have an additional
1402+
// provider, which we can safely ignore.
13901403
if asset::free_to_stake::<T>(&stash) >= asset::existential_deposit::<T>() {
13911404
2
13921405
} else {
@@ -1399,10 +1412,9 @@ impl<T: Config> Pallet<T> {
13991412
// if actual provider is less than expected, it is already migrated.
14001413
ensure!(actual_providers == expected_providers, Error::<T>::AlreadyMigrated);
14011414

1402-
// dec provider
14031415
let _ = frame_system::Pallet::<T>::dec_providers(&stash)?;
14041416

1405-
return Ok(())
1417+
Ok(())
14061418
}
14071419
}
14081420

substrate/frame/staking/src/pallet/mod.rs

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2604,37 +2604,5 @@ pub mod pallet {
26042604

26052605
Ok(Pays::No.into())
26062606
}
2607-
2608-
/// Adjusts the staking ledger by withdrawing any excess staked amount.
2609-
///
2610-
/// This function corrects cases where a user's recorded stake in the ledger
2611-
/// exceeds their actual staked funds. This situation can arise due to cases such as
2612-
/// external slashing by another pallet, leading to an inconsistency between the ledger
2613-
/// and the actual stake.
2614-
#[pallet::call_index(32)]
2615-
#[pallet::weight(T::DbWeight::get().reads_writes(2, 1))]
2616-
pub fn withdraw_overstake(origin: OriginFor<T>, stash: T::AccountId) -> DispatchResult {
2617-
let _ = ensure_signed(origin)?;
2618-
2619-
let ledger = Self::ledger(Stash(stash.clone()))?;
2620-
let actual_stake = asset::staked::<T>(&stash);
2621-
let force_withdraw_amount = ledger.total.defensive_saturating_sub(actual_stake);
2622-
2623-
// ensure there is something to force unstake.
2624-
ensure!(!force_withdraw_amount.is_zero(), Error::<T>::BoundNotMet);
2625-
2626-
// we ignore if active is 0. It implies the locked amount is not actively staked. The
2627-
// account can still get away from potential slash, but we can't do much better here.
2628-
StakingLedger {
2629-
total: actual_stake,
2630-
active: ledger.active.saturating_sub(force_withdraw_amount),
2631-
..ledger
2632-
}
2633-
.update()?;
2634-
2635-
Self::deposit_event(Event::<T>::Withdrawn { stash, amount: force_withdraw_amount });
2636-
2637-
Ok(())
2638-
}
26392607
}
26402608
}

substrate/frame/staking/src/tests.rs

Lines changed: 87 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use sp_runtime::{
4949
};
5050
use sp_staking::{
5151
offence::{OffenceDetails, OnOffenceHandler},
52-
SessionIndex, Stake, StakingInterface,
52+
SessionIndex, StakingInterface,
5353
};
5454
use substrate_test_utils::assert_eq_uvec;
5555

@@ -5099,72 +5099,6 @@ fn restricted_accounts_can_only_withdraw() {
50995099
})
51005100
}
51015101

5102-
#[test]
5103-
fn permissionless_withdraw_overstake() {
5104-
ExtBuilder::default().build_and_execute(|| {
5105-
// Given Alice, Bob and Charlie with some stake.
5106-
let alice = 301;
5107-
let bob = 302;
5108-
let charlie = 303;
5109-
let _ = Balances::make_free_balance_be(&alice, 500);
5110-
let _ = Balances::make_free_balance_be(&bob, 500);
5111-
let _ = Balances::make_free_balance_be(&charlie, 500);
5112-
assert_ok!(Staking::bond(RuntimeOrigin::signed(alice), 100, RewardDestination::Staked));
5113-
assert_ok!(Staking::bond(RuntimeOrigin::signed(bob), 100, RewardDestination::Staked));
5114-
assert_ok!(Staking::bond(RuntimeOrigin::signed(charlie), 100, RewardDestination::Staked));
5115-
5116-
// WHEN: charlie is partially unbonding.
5117-
assert_ok!(Staking::unbond(RuntimeOrigin::signed(charlie), 90));
5118-
let charlie_ledger = StakingLedger::<Test>::get(StakingAccount::Stash(charlie)).unwrap();
5119-
5120-
// AND: alice and charlie ledger having higher value than actual stake.
5121-
Ledger::<Test>::insert(alice, StakingLedger::<Test>::new(alice, 200));
5122-
Ledger::<Test>::insert(
5123-
charlie,
5124-
StakingLedger { stash: charlie, total: 200, active: 200 - 90, ..charlie_ledger },
5125-
);
5126-
5127-
// THEN overstake can be permissionlessly withdrawn.
5128-
System::reset_events();
5129-
5130-
// Alice stake is corrected.
5131-
assert_eq!(
5132-
<Staking as StakingInterface>::stake(&alice).unwrap(),
5133-
Stake { total: 200, active: 200 }
5134-
);
5135-
assert_ok!(Staking::withdraw_overstake(RuntimeOrigin::signed(1), alice));
5136-
assert_eq!(
5137-
<Staking as StakingInterface>::stake(&alice).unwrap(),
5138-
Stake { total: 100, active: 100 }
5139-
);
5140-
5141-
// Charlie who is partially withdrawing also gets their stake corrected.
5142-
assert_eq!(
5143-
<Staking as StakingInterface>::stake(&charlie).unwrap(),
5144-
Stake { total: 200, active: 110 }
5145-
);
5146-
assert_ok!(Staking::withdraw_overstake(RuntimeOrigin::signed(1), charlie));
5147-
assert_eq!(
5148-
<Staking as StakingInterface>::stake(&charlie).unwrap(),
5149-
Stake { total: 200 - 100, active: 110 - 100 }
5150-
);
5151-
5152-
assert_eq!(
5153-
staking_events_since_last_call(),
5154-
vec![
5155-
Event::Withdrawn { stash: alice, amount: 200 - 100 },
5156-
Event::Withdrawn { stash: charlie, amount: 200 - 100 }
5157-
]
5158-
);
5159-
5160-
// but Bob ledger is fine and that cannot be withdrawn.
5161-
assert_noop!(
5162-
Staking::withdraw_overstake(RuntimeOrigin::signed(1), bob),
5163-
Error::<Test>::BoundNotMet
5164-
);
5165-
});
5166-
}
5167-
51685102
mod election_data_provider {
51695103
use super::*;
51705104
use frame_election_provider_support::ElectionDataProvider;
@@ -9071,6 +9005,92 @@ mod hold_migration {
90719005
});
90729006
}
90739007

9008+
#[test]
9009+
fn overstaked_and_partially_unbonding() {
9010+
ExtBuilder::default().has_stakers(true).build_and_execute(|| {
9011+
// GIVEN alice who is a nominator with T::OldCurrency.
9012+
let alice = 300;
9013+
// 1000 + ED
9014+
let _ = Balances::make_free_balance_be(&alice, 1001);
9015+
let stake = 600;
9016+
let reserved_by_another_pallet = 400;
9017+
assert_ok!(Staking::bond(
9018+
RuntimeOrigin::signed(alice),
9019+
stake,
9020+
RewardDestination::Staked
9021+
));
9022+
9023+
// AND Alice is partially unbonding.
9024+
assert_ok!(Staking::unbond(RuntimeOrigin::signed(alice), 300));
9025+
9026+
// AND Alice has some funds reserved with another pallet.
9027+
assert_ok!(Balances::reserve(&alice, reserved_by_another_pallet));
9028+
9029+
// convert stake to T::OldCurrency.
9030+
testing_utils::migrate_to_old_currency::<Test>(alice);
9031+
assert_eq!(asset::staked::<Test>(&alice), 0);
9032+
assert_eq!(Balances::balance_locked(STAKING_ID, &alice), stake);
9033+
9034+
// ledger has correct amount staked.
9035+
assert_eq!(
9036+
<Staking as StakingInterface>::stake(&alice),
9037+
Ok(Stake { total: stake, active: stake - 300 })
9038+
);
9039+
9040+
// Alice becomes overstaked by withdrawing some staked balance.
9041+
assert_ok!(Balances::transfer_allow_death(
9042+
RuntimeOrigin::signed(alice),
9043+
10,
9044+
reserved_by_another_pallet
9045+
));
9046+
9047+
let expected_force_withdraw = reserved_by_another_pallet;
9048+
9049+
// ledger mutation would fail in this case before migration because of failing hold.
9050+
assert_noop!(
9051+
Staking::unbond(RuntimeOrigin::signed(alice), 100),
9052+
Error::<Test>::NotEnoughFunds
9053+
);
9054+
9055+
// clear events
9056+
System::reset_events();
9057+
9058+
// WHEN alice currency is migrated.
9059+
assert_ok!(Staking::migrate_currency(RuntimeOrigin::signed(1), alice));
9060+
9061+
// THEN
9062+
let expected_hold = stake - expected_force_withdraw;
9063+
// ensure no lock
9064+
assert_eq!(Balances::balance_locked(STAKING_ID, &alice), 0);
9065+
// ensure stake and hold are same.
9066+
assert_eq!(
9067+
<Staking as StakingInterface>::stake(&alice),
9068+
// expected stake is 0 since force withdrawn (400) is taken out completely of
9069+
// active stake.
9070+
Ok(Stake { total: expected_hold, active: 0 })
9071+
);
9072+
9073+
assert_eq!(asset::staked::<Test>(&alice), expected_hold);
9074+
// ensure events are emitted.
9075+
assert_eq!(
9076+
staking_events_since_last_call(),
9077+
vec![Event::CurrencyMigrated {
9078+
stash: alice,
9079+
force_withdraw: expected_force_withdraw
9080+
}]
9081+
);
9082+
9083+
// ensure cannot migrate again.
9084+
assert_noop!(
9085+
Staking::migrate_currency(RuntimeOrigin::signed(1), alice),
9086+
Error::<Test>::AlreadyMigrated
9087+
);
9088+
9089+
// unbond works after migration.
9090+
assert_ok!(Staking::unbond(RuntimeOrigin::signed(alice), 100));
9091+
});
9092+
}
9093+
90749094
#[test]
90759095
fn virtual_staker_consumer_provider_dec() {
90769096
// Ensure virtual stakers consumer and provider count is decremented.

0 commit comments

Comments
 (0)