Skip to content

Commit 038fbbf

Browse files
Ank4ngithub-actions[bot]
authored andcommitted
[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>
1 parent f704880 commit 038fbbf

File tree

6 files changed

+189
-122
lines changed

6 files changed

+189
-122
lines changed

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
@@ -1356,18 +1356,20 @@ impl<T: Config> Pallet<T> {
13561356
} else {
13571357
// if we are here, it means we cannot hold all user stake. We will do a force withdraw
13581358
// from ledger, but that's okay since anyways user do not have funds for it.
1359-
let force_withdraw = staked.saturating_sub(max_hold);
1360-
1361-
// we ignore if active is 0. It implies the locked amount is not actively staked. The
1362-
// account can still get away from potential slash but we can't do much better here.
1363-
StakingLedger {
1364-
total: max_hold,
1365-
active: ledger.active.saturating_sub(force_withdraw),
1366-
// we are not changing the stash, so we can keep the stash.
1367-
..ledger
1368-
}
1369-
.update()?;
1370-
force_withdraw
1359+
1360+
let old_total = ledger.total;
1361+
// update ledger with total stake as max_hold.
1362+
let updated_ledger = ledger.update_total_stake(max_hold);
1363+
1364+
// new total stake in ledger.
1365+
let new_total = updated_ledger.total;
1366+
debug_assert_eq!(new_total, max_hold);
1367+
1368+
// update ledger in storage.
1369+
updated_ledger.update()?;
1370+
1371+
// return the diff
1372+
old_total.defensive_saturating_sub(new_total)
13711373
};
13721374

13731375
// Get rid of the extra consumer we used to have with OldCurrency.
@@ -1377,20 +1379,31 @@ impl<T: Config> Pallet<T> {
13771379
Ok(())
13781380
}
13791381

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

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

13911403
let expected_providers =
1392-
// provider is expected to be 1 but someone can always transfer some free funds to
1393-
// these accounts, increasing the provider.
1404+
// We expect these accounts to have only one provider, and hold no balance. However, if
1405+
// someone mischievously sends some funds to these accounts, they may have an additional
1406+
// provider, which we can safely ignore.
13941407
if asset::free_to_stake::<T>(&stash) >= asset::existential_deposit::<T>() {
13951408
2
13961409
} else {
@@ -1403,10 +1416,9 @@ impl<T: Config> Pallet<T> {
14031416
// if actual provider is less than expected, it is already migrated.
14041417
ensure!(actual_providers == expected_providers, Error::<T>::AlreadyMigrated);
14051418

1406-
// dec provider
14071419
let _ = frame_system::Pallet::<T>::dec_providers(&stash)?;
14081420

1409-
return Ok(())
1421+
Ok(())
14101422
}
14111423
}
14121424

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

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

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

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)