Skip to content

Commit 5198623

Browse files
authored
Fix: dust unbonded for zero existential deposit (#4364)
When a staker unbonds and withdraws, it is possible that their stash will contain less currency than the existential deposit. If that happens, their stash is reaped. But if the existential deposit is zero, the reap is not triggered. This PR adjusts `pallet_staking` to reap a stash in the special case that the stash value is zero and the existential deposit is zero. This change is important for blockchains built on substrate that require an existential deposit of zero, becuase it conserves valued storage space. There are two places in which ledgers are checked to determine if their value is less than the existential deposit and they should be reaped: in the methods `do_withdraw_unbonded` and `reap_stash`. When the check is made, the condition `ledger_total == 0` is also checked. If `ledger_total` is zero, then it must be below any existential deposit greater than zero and equal to an existential deposit of 0. I added a new test for each method to confirm the change behaves as expected. Closes #4340 --------- Co-authored-by: command-bot <>
1 parent 1680977 commit 5198623

File tree

4 files changed

+113
-4
lines changed

4 files changed

+113
-4
lines changed

prdoc/pr_4364.prdoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
title: Fix dust unbonded for zero existential deposit
2+
3+
doc:
4+
- audience: Runtime Dev
5+
description: |
6+
When a staker unbonds and withdraws, it is possible that their stash will contain less currency than the existential deposit. If that happens, their stash is reaped. But if the existential deposit is zero, the reap is not triggered. This PR adjusts pallet_staking to reap a stash in the special case that the stash value is zero and the existential deposit is zero.
7+
8+
crates:
9+
- name: pallet-staking
10+
bump: patch

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,9 @@ impl<T: Config> Pallet<T> {
198198
}
199199
let new_total = ledger.total;
200200

201+
let ed = T::Currency::minimum_balance();
201202
let used_weight =
202-
if ledger.unlocking.is_empty() && ledger.active < T::Currency::minimum_balance() {
203+
if ledger.unlocking.is_empty() && (ledger.active < ed || ledger.active.is_zero()) {
203204
// This account must have called `unbond()` with some value that caused the active
204205
// portion to fall below existential deposit + will have no more unlocking chunks
205206
// left. We can now safely remove all staking-related information.

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,8 @@ pub mod pallet {
938938
/// - Three extra DB entries.
939939
///
940940
/// NOTE: Two of the storage writes (`Self::bonded`, `Self::payee`) are _never_ cleaned
941-
/// unless the `origin` falls below _existential deposit_ and gets removed as dust.
941+
/// unless the `origin` falls below _existential deposit_ (or equal to 0) and gets removed
942+
/// as dust.
942943
#[pallet::call_index(0)]
943944
#[pallet::weight(T::WeightInfo::bond())]
944945
pub fn bond(
@@ -1615,6 +1616,7 @@ pub mod pallet {
16151616
///
16161617
/// 1. the `total_balance` of the stash is below existential deposit.
16171618
/// 2. or, the `ledger.total` of the stash is below existential deposit.
1619+
/// 3. or, existential deposit is zero and either `total_balance` or `ledger.total` is zero.
16181620
///
16191621
/// The former can happen in cases like a slash; the latter when a fully unbonded account
16201622
/// is still receiving staking rewards in `RewardDestination::Staked`.
@@ -1640,8 +1642,13 @@ pub mod pallet {
16401642
ensure!(!Self::is_virtual_staker(&stash), Error::<T>::VirtualStakerNotAllowed);
16411643

16421644
let ed = T::Currency::minimum_balance();
1643-
let reapable = T::Currency::total_balance(&stash) < ed ||
1644-
Self::ledger(Stash(stash.clone())).map(|l| l.total).unwrap_or_default() < ed;
1645+
let origin_balance = T::Currency::total_balance(&stash);
1646+
let ledger_total =
1647+
Self::ledger(Stash(stash.clone())).map(|l| l.total).unwrap_or_default();
1648+
let reapable = origin_balance < ed ||
1649+
origin_balance.is_zero() ||
1650+
ledger_total < ed ||
1651+
ledger_total.is_zero();
16451652
ensure!(reapable, Error::<T>::FundedTarget);
16461653

16471654
// Remove all staking-related information and lock.

substrate/frame/staking/src/tests.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1931,6 +1931,44 @@ fn reap_stash_works() {
19311931
});
19321932
}
19331933

1934+
#[test]
1935+
fn reap_stash_works_with_existential_deposit_zero() {
1936+
ExtBuilder::default()
1937+
.existential_deposit(0)
1938+
.balance_factor(10)
1939+
.build_and_execute(|| {
1940+
// given
1941+
assert_eq!(Balances::balance_locked(STAKING_ID, &11), 10 * 1000);
1942+
assert_eq!(Staking::bonded(&11), Some(11));
1943+
1944+
assert!(<Ledger<Test>>::contains_key(&11));
1945+
assert!(<Bonded<Test>>::contains_key(&11));
1946+
assert!(<Validators<Test>>::contains_key(&11));
1947+
assert!(<Payee<Test>>::contains_key(&11));
1948+
1949+
// stash is not reapable
1950+
assert_noop!(
1951+
Staking::reap_stash(RuntimeOrigin::signed(20), 11, 0),
1952+
Error::<Test>::FundedTarget
1953+
);
1954+
1955+
// no easy way to cause an account to go below ED, we tweak their staking ledger
1956+
// instead.
1957+
Ledger::<Test>::insert(11, StakingLedger::<Test>::new(11, 0));
1958+
1959+
// reap-able
1960+
assert_ok!(Staking::reap_stash(RuntimeOrigin::signed(20), 11, 0));
1961+
1962+
// then
1963+
assert!(!<Ledger<Test>>::contains_key(&11));
1964+
assert!(!<Bonded<Test>>::contains_key(&11));
1965+
assert!(!<Validators<Test>>::contains_key(&11));
1966+
assert!(!<Payee<Test>>::contains_key(&11));
1967+
// lock is removed.
1968+
assert_eq!(Balances::balance_locked(STAKING_ID, &11), 0);
1969+
});
1970+
}
1971+
19341972
#[test]
19351973
fn switching_roles() {
19361974
// Test that it should be possible to switch between roles (nominator, validator, idle) with
@@ -6953,6 +6991,59 @@ mod staking_interface {
69536991
});
69546992
}
69556993

6994+
#[test]
6995+
fn do_withdraw_unbonded_can_kill_stash_with_existential_deposit_zero() {
6996+
ExtBuilder::default()
6997+
.existential_deposit(0)
6998+
.nominate(false)
6999+
.build_and_execute(|| {
7000+
// Initial state of 11
7001+
assert_eq!(Staking::bonded(&11), Some(11));
7002+
assert_eq!(
7003+
Staking::ledger(11.into()).unwrap(),
7004+
StakingLedgerInspect {
7005+
stash: 11,
7006+
total: 1000,
7007+
active: 1000,
7008+
unlocking: Default::default(),
7009+
legacy_claimed_rewards: bounded_vec![],
7010+
}
7011+
);
7012+
assert_eq!(
7013+
Staking::eras_stakers(active_era(), &11),
7014+
Exposure { total: 1000, own: 1000, others: vec![] }
7015+
);
7016+
7017+
// Unbond all of the funds in stash.
7018+
Staking::chill(RuntimeOrigin::signed(11)).unwrap();
7019+
Staking::unbond(RuntimeOrigin::signed(11), 1000).unwrap();
7020+
assert_eq!(
7021+
Staking::ledger(11.into()).unwrap(),
7022+
StakingLedgerInspect {
7023+
stash: 11,
7024+
total: 1000,
7025+
active: 0,
7026+
unlocking: bounded_vec![UnlockChunk { value: 1000, era: 3 }],
7027+
legacy_claimed_rewards: bounded_vec![],
7028+
},
7029+
);
7030+
7031+
// trigger future era.
7032+
mock::start_active_era(3);
7033+
7034+
// withdraw unbonded
7035+
assert_ok!(Staking::withdraw_unbonded(RuntimeOrigin::signed(11), 0));
7036+
7037+
// empty stash has been reaped
7038+
assert!(!<Ledger<Test>>::contains_key(&11));
7039+
assert!(!<Bonded<Test>>::contains_key(&11));
7040+
assert!(!<Validators<Test>>::contains_key(&11));
7041+
assert!(!<Payee<Test>>::contains_key(&11));
7042+
// lock is removed.
7043+
assert_eq!(Balances::balance_locked(STAKING_ID, &11), 0);
7044+
});
7045+
}
7046+
69567047
#[test]
69577048
fn status() {
69587049
ExtBuilder::default().build_and_execute(|| {

0 commit comments

Comments
 (0)