Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
298 changes: 164 additions & 134 deletions polkadot/runtime/westend/src/weights/pallet_staking.rs

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions prdoc/pr_3639.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
title: Prevents staking controllers from becoming stashes of different ledgers; Ensures that no ledger in bad state is mutated.

doc:
- audience: Runtime User
description: |
This PR introduces a fix to the staking logic which prevents an existing controller from bonding as a stash of another ledger, which
lead to staking ledger inconsistencies down the line. In addition, it adds a few (temporary) gates to prevent ledgers that are already
in a bad state from mutating its state.

In summary:
* Checks if stash is already a controller when calling `Call::bond` and fails if that's the case;
* Ensures that all fetching ledgers from storage are done through the `StakingLedger` API;
* Ensures that a `Error::BadState` is returned if the ledger bonding is in a bad state. This prevents bad ledgers from mutating (e.g.
`bond_extra`, `set_controller`, etc) its state and avoid further data inconsistencies.
* Prevents stashes which are controllers or another ledger from calling `set_controller`, since that may lead to a bad state.
* Adds further try-state runtime checks that check if there are ledgers in a bad state based on their bonded metadata.

crates:
- name: pallet-staking
20 changes: 20 additions & 0 deletions prdoc/pr_3706.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Schema: Polkadot SDK PRDoc Schema (prdoc) v1.0.0
# See doc at https://raw.githubusercontent.com/paritytech/polkadot-sdk/master/prdoc/schema_user.json

title: Extrinsic to restore corrupted staking ledgers

doc:
- audience: Runtime User
description: |
This PR adds a new extrinsic `Call::restore_ledger ` gated by `StakingAdmin` origin that restores a corrupted staking ledger. This extrinsic will be used to recover ledgers that were affected by the issue discussed in https://github.com/paritytech/polkadot-sdk/issues/3245.
The extrinsic will re-write the storage items associated with a stash account provided as input parameter. The data used to reset the ledger can be either i) fetched on-chain or ii) partially/totally set by the input parameters of the call.

Changes introduced:
- Adds `Call::restore_ledger ` extrinsic to recover a corrupted ledger;
- Adds trait `frame_support::traits::currency::InspectLockableCurrency` to allow external pallets to read current locks given an account and lock ID;
- Implements the `InspectLockableCurrency` in the pallet-balances.
- Adds staking locks try-runtime checks (https://github.com/paritytech/polkadot-sdk/issues/3751)

crates:
- name: pallet-staking
- name: pallet-balances
13 changes: 11 additions & 2 deletions substrate/frame/balances/src/impl_currency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use frame_support::{
tokens::{fungible, BalanceStatus as Status, Fortitude::Polite, Precision::BestEffort},
Currency, DefensiveSaturating, ExistenceRequirement,
ExistenceRequirement::AllowDeath,
Get, Imbalance, LockIdentifier, LockableCurrency, NamedReservableCurrency,
ReservableCurrency, SignedImbalance, TryDrop, WithdrawReasons,
Get, Imbalance, InspectLockableCurrency, LockIdentifier, LockableCurrency,
NamedReservableCurrency, ReservableCurrency, SignedImbalance, TryDrop, WithdrawReasons,
},
};
use frame_system::pallet_prelude::BlockNumberFor;
Expand Down Expand Up @@ -918,3 +918,12 @@ where
Self::update_locks(who, &locks[..]);
}
}

impl<T: Config<I>, I: 'static> InspectLockableCurrency<T::AccountId> for Pallet<T, I> {
fn balance_locked(id: LockIdentifier, who: &T::AccountId) -> Self::Balance {
Self::locks(who)
.into_iter()
.filter(|l| l.id == id)
.fold(Zero::zero(), |acc, l| acc + l.amount)
}
}
22 changes: 20 additions & 2 deletions substrate/frame/balances/src/tests/currency_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ use frame_support::{
BalanceStatus::{Free, Reserved},
Currency,
ExistenceRequirement::{self, AllowDeath, KeepAlive},
Hooks, LockIdentifier, LockableCurrency, NamedReservableCurrency, ReservableCurrency,
WithdrawReasons,
Hooks, InspectLockableCurrency, LockIdentifier, LockableCurrency, NamedReservableCurrency,
ReservableCurrency, WithdrawReasons,
},
StorageNoopGuard,
};
Expand Down Expand Up @@ -88,6 +88,24 @@ fn basic_locking_should_work() {
});
}

#[test]
fn inspect_lock_should_work() {
ExtBuilder::default()
.existential_deposit(1)
.monied(true)
.build_and_execute_with(|| {
Balances::set_lock(ID_1, &1, 10, WithdrawReasons::all());
Balances::set_lock(ID_2, &1, 10, WithdrawReasons::all());
Balances::set_lock(ID_1, &2, 20, WithdrawReasons::all());

assert_eq!(<Balances as InspectLockableCurrency<_>>::balance_locked(ID_1, &1), 10);
assert_eq!(<Balances as InspectLockableCurrency<_>>::balance_locked(ID_2, &1), 10);
assert_eq!(<Balances as InspectLockableCurrency<_>>::balance_locked(ID_1, &2), 20);
assert_eq!(<Balances as InspectLockableCurrency<_>>::balance_locked(ID_2, &2), 0);
assert_eq!(<Balances as InspectLockableCurrency<_>>::balance_locked(ID_1, &3), 0);
})
}

#[test]
fn account_should_be_reaped() {
ExtBuilder::default()
Expand Down
2 changes: 1 addition & 1 deletion substrate/frame/staking/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ frame-benchmarking = { path = "../benchmarking", default-features = false, optio
rand_chacha = { version = "0.2", default-features = false, optional = true }

[dev-dependencies]
pallet-balances = { path = "../balances" }
sp-tracing = { path = "../../primitives/tracing" }
sp-core = { path = "../../primitives/core" }
sp-npos-elections = { path = "../../primitives/npos-elections" }
pallet-balances = { path = "../balances" }
pallet-timestamp = { path = "../timestamp" }
pallet-staking-reward-curve = { path = "reward-curve" }
pallet-bags-list = { path = "../bags-list" }
Expand Down
9 changes: 9 additions & 0 deletions substrate/frame/staking/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,15 @@ benchmarks! {
assert_eq!(MinCommission::<T>::get(), Perbill::from_percent(100));
}

restore_ledger {
let (stash, controller) = create_stash_controller::<T>(0, 100, RewardDestination::Staked)?;
// corrupt ledger.
Ledger::<T>::remove(controller);
}: _(RawOrigin::Root, stash.clone(), None, None, None)
verify {
assert_eq!(Staking::<T>::inspect_bond_state(&stash), Ok(LedgerIntegrityState::Ok));
}

impl_benchmark_test_suite!(
Staking,
crate::mock::ExtBuilder::default().has_stakers(true),
Expand Down
61 changes: 53 additions & 8 deletions substrate/frame/staking/src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
//! state consistency.

use frame_support::{
defensive,
traits::{LockableCurrency, WithdrawReasons},
defensive, ensure,
traits::{Defensive, LockableCurrency, WithdrawReasons},
};
use sp_staking::StakingAccount;
use sp_std::prelude::*;
Expand Down Expand Up @@ -106,18 +106,39 @@ impl<T: Config> StakingLedger<T> {
/// This getter can be called with either a controller or stash account, provided that the
/// account is properly wrapped in the respective [`StakingAccount`] variant. This is meant to
/// abstract the concept of controller/stash accounts from the caller.
///
/// Returns [`Error::BadState`] when a bond is in "bad state". A bond is in a bad state when a
/// stash has a controller which is bonding a ledger associated with another stash.
pub(crate) fn get(account: StakingAccount<T::AccountId>) -> Result<StakingLedger<T>, Error<T>> {
let controller = match account {
StakingAccount::Stash(stash) => <Bonded<T>>::get(stash).ok_or(Error::<T>::NotStash),
StakingAccount::Controller(controller) => Ok(controller),
}?;
let (stash, controller) = match account.clone() {
StakingAccount::Stash(stash) =>
(stash.clone(), <Bonded<T>>::get(&stash).ok_or(Error::<T>::NotStash)?),
StakingAccount::Controller(controller) => (
Ledger::<T>::get(&controller)
.map(|l| l.stash)
.ok_or(Error::<T>::NotController)?,
controller,
),
};

<Ledger<T>>::get(&controller)
let ledger = <Ledger<T>>::get(&controller)
.map(|mut ledger| {
ledger.controller = Some(controller.clone());
ledger
})
.ok_or(Error::<T>::NotController)
.ok_or(Error::<T>::NotController)?;

// if ledger bond is in a bad state, return error to prevent applying operations that may
// further spoil the ledger's state. A bond is in bad state when the bonded controller is
// associted with a different ledger (i.e. a ledger with a different stash).
//
// See <https://github.com/paritytech/polkadot-sdk/issues/3245> for more details.
ensure!(
Bonded::<T>::get(&stash) == Some(controller) && ledger.stash == stash,
Error::<T>::BadState
);

Ok(ledger)
}

/// Returns the reward destination of a staking ledger, stored in [`Payee`].
Expand Down Expand Up @@ -201,6 +222,30 @@ impl<T: Config> StakingLedger<T> {
}
}

/// Sets the ledger controller to its stash.
pub(crate) fn set_controller_to_stash(self) -> Result<(), Error<T>> {
let controller = self.controller.as_ref()
.defensive_proof("Ledger's controller field didn't exist. The controller should have been fetched using StakingLedger.")
.ok_or(Error::<T>::NotController)?;

ensure!(self.stash != *controller, Error::<T>::AlreadyPaired);

// check if the ledger's stash is a controller of another ledger.
if let Some(bonded_ledger) = Ledger::<T>::get(&self.stash) {
// there is a ledger bonded by the stash. In this case, the stash of the bonded ledger
// should be the same as the ledger's stash. Otherwise fail to prevent data
// inconsistencies. See <https://github.com/paritytech/polkadot-sdk/pull/3639> for more
// details.
ensure!(bonded_ledger.stash == self.stash, Error::<T>::BadState);
}

<Ledger<T>>::remove(&controller);
<Ledger<T>>::insert(&self.stash, &self);
<Bonded<T>>::insert(&self.stash, &self.stash);

Ok(())
}

/// Clears all data related to a staking ledger and its bond in both [`Ledger`] and [`Bonded`]
/// storage items and updates the stash staking lock.
pub(crate) fn kill(stash: &T::AccountId) -> Result<(), Error<T>> {
Expand Down
14 changes: 14 additions & 0 deletions substrate/frame/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,20 @@ pub struct StakingLedger<T: Config> {
controller: Option<T::AccountId>,
}

/// State of a ledger with regards with its data and metadata integrity.
#[derive(PartialEq, Debug)]
enum LedgerIntegrityState {
/// Ledger, bond and corresponding staking lock is OK.
Ok,
/// Ledger and/or bond is corrupted. This means that the bond has a ledger with a different
/// stash than the bonded stash.
Corrupted,
/// Ledger was corrupted and it has been killed.
CorruptedKilled,
/// Ledger and bond are OK, however the ledger's stash lock is out of sync.
LockCorrupted,
}

impl<T: Config> StakingLedger<T> {
/// Remove entries from `unlocking` that are sufficiently old and reduce the
/// total by the sum of their balances.
Expand Down
86 changes: 84 additions & 2 deletions substrate/frame/staking/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ use frame_election_provider_support::{
use frame_support::{
assert_ok, derive_impl, ord_parameter_types, parameter_types,
traits::{
ConstU64, Currency, EitherOfDiverse, FindAuthor, Get, Hooks, Imbalance, OnUnbalanced,
OneSessionHandler,
ConstU64, Currency, EitherOfDiverse, FindAuthor, Get, Hooks, Imbalance, LockableCurrency,
OnUnbalanced, OneSessionHandler, WithdrawReasons,
},
weights::constants::RocksDbWeight,
};
Expand Down Expand Up @@ -813,6 +813,88 @@ pub(crate) fn bond_controller_stash(controller: AccountId, stash: AccountId) ->
Ok(())
}

// simulates `set_controller` without corrupted ledger checks for testing purposes.
pub(crate) fn set_controller_no_checks(stash: &AccountId) {
let controller = Bonded::<Test>::get(stash).expect("testing stash should be bonded");
let ledger = Ledger::<Test>::get(&controller).expect("testing ledger should exist");

Ledger::<Test>::remove(&controller);
Ledger::<Test>::insert(stash, ledger);
Bonded::<Test>::insert(stash, stash);
}

// simulates `bond_extra` without corrupted ledger checks for testing purposes.
pub(crate) fn bond_extra_no_checks(stash: &AccountId, amount: Balance) {
let controller = Bonded::<Test>::get(stash).expect("bond must exist to bond_extra");
let mut ledger = Ledger::<Test>::get(&controller).expect("ledger must exist to bond_extra");

let new_total = ledger.total + amount;
Balances::set_lock(crate::STAKING_ID, stash, new_total, WithdrawReasons::all());
ledger.total = new_total;
ledger.active = new_total;
Ledger::<Test>::insert(controller, ledger);
}

pub(crate) fn setup_double_bonded_ledgers() {
let init_ledgers = Ledger::<Test>::iter().count();

let _ = Balances::make_free_balance_be(&333, 2000);
let _ = Balances::make_free_balance_be(&444, 2000);
let _ = Balances::make_free_balance_be(&555, 2000);
let _ = Balances::make_free_balance_be(&777, 2000);

assert_ok!(Staking::bond(RuntimeOrigin::signed(333), 10, RewardDestination::Staked));
assert_ok!(Staking::bond(RuntimeOrigin::signed(444), 20, RewardDestination::Staked));
assert_ok!(Staking::bond(RuntimeOrigin::signed(555), 20, RewardDestination::Staked));
// not relevant to the test case, but ensures try-runtime checks pass.
[333, 444, 555]
.iter()
.for_each(|s| Payee::<Test>::insert(s, RewardDestination::Staked));

// we want to test the case where a controller can also be a stash of another ledger.
// for that, we change the controller/stash bonding so that:
// * 444 becomes controller of 333.
// * 555 becomes controller of 444.
// * 777 becomes controller of 555.
let ledger_333 = Ledger::<Test>::get(333).unwrap();
let ledger_444 = Ledger::<Test>::get(444).unwrap();
let ledger_555 = Ledger::<Test>::get(555).unwrap();

// 777 becomes controller of 555.
Bonded::<Test>::mutate(555, |controller| *controller = Some(777));
Ledger::<Test>::insert(777, ledger_555);

// 555 becomes controller of 444.
Bonded::<Test>::mutate(444, |controller| *controller = Some(555));
Ledger::<Test>::insert(555, ledger_444);

// 444 becomes controller of 333.
Bonded::<Test>::mutate(333, |controller| *controller = Some(444));
Ledger::<Test>::insert(444, ledger_333);

// 333 is not controller anymore.
Ledger::<Test>::remove(333);

// checks. now we have:
// * +3 ledgers
assert_eq!(Ledger::<Test>::iter().count(), 3 + init_ledgers);

// * stash 333 has controller 444.
assert_eq!(Bonded::<Test>::get(333), Some(444));
assert_eq!(StakingLedger::<Test>::paired_account(StakingAccount::Stash(333)), Some(444));
assert_eq!(Ledger::<Test>::get(444).unwrap().stash, 333);

// * stash 444 has controller 555.
assert_eq!(Bonded::<Test>::get(444), Some(555));
assert_eq!(StakingLedger::<Test>::paired_account(StakingAccount::Stash(444)), Some(555));
assert_eq!(Ledger::<Test>::get(555).unwrap().stash, 444);

// * stash 555 has controller 777.
assert_eq!(Bonded::<Test>::get(555), Some(777));
assert_eq!(StakingLedger::<Test>::paired_account(StakingAccount::Stash(555)), Some(777));
assert_eq!(Ledger::<Test>::get(777).unwrap().stash, 555);
}

#[macro_export]
macro_rules! assert_session_era {
($session:expr, $era:expr) => {
Expand Down
Loading