From e504c41a5adbd5e6d9a7764c07f6dcf47b2dae77 Mon Sep 17 00:00:00 2001 From: Ankan <10196091+Ank4n@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:05:34 +0200 Subject: [PATCH] Allow privileged virtual bond in Staking pallet (#3889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the first PR in preparation for https://github.com/paritytech/polkadot-sdk/issues/454. ## Follow ups: - https://github.com/paritytech/polkadot-sdk/pull/3904. - https://github.com/paritytech/polkadot-sdk/pull/3905. Overall changes are documented here (lot more visual 😍): https://hackmd.io/@ak0n/454-np-governance [Maybe followup](https://github.com/paritytech/polkadot-sdk/issues/4217) with migration of storage item `VirtualStakers` as a bool or enum in `Ledger`. ## Context We want to achieve a way for a user (`Delegator`) to delegate their funds to another account (`Agent`). Delegate implies the funds are locked in delegator account itself. Agent can act on behalf of delegator to stake directly on Staking pallet. The delegation feature is added to Staking via another pallet `delegated-staking` worked on [here](https://github.com/paritytech/polkadot-sdk/pull/3904). ## Introduces: ### StakingUnchecked Trait As the name implies, this trait allows unchecked (non-locked) mutation of staking ledger. These apis are only meant to be used by other pallets in the runtime and should not be exposed directly to user code path. Also related: https://github.com/paritytech/polkadot-sdk/issues/3888. ### Virtual Bond Allows other pallets to stake via staking pallet while managing the locks on these accounts themselves. Introduces another storage `VirtualStakers` that whitelist these accounts. We also restrict virtual stakers to set reward account as themselves. Since the account has no locks, we cannot support compounding of rewards. Conservatively, we require them to set a separate account different from the staker. Since these are code managed, it should be easy for another pallet to redistribute reward and rebond them. ### Slashes Since there is no actual lock maintained by staking-pallet for virtual stakers, this pallet does not apply any slashes. It is then important for pallets managing virtual stakers to listen to slashing events and apply necessary slashes. --- prdoc/pr_3889.prdoc | 14 ++ substrate/frame/nomination-pools/src/mock.rs | 8 + substrate/frame/staking/src/ledger.rs | 43 ++-- substrate/frame/staking/src/mock.rs | 23 +- substrate/frame/staking/src/pallet/impls.rs | 155 +++++++++++- substrate/frame/staking/src/pallet/mod.rs | 39 ++- substrate/frame/staking/src/slashing.rs | 22 +- substrate/frame/staking/src/tests.rs | 243 ++++++++++++++++++- substrate/primitives/staking/src/lib.rs | 38 ++- 9 files changed, 513 insertions(+), 72 deletions(-) create mode 100644 prdoc/pr_3889.prdoc diff --git a/prdoc/pr_3889.prdoc b/prdoc/pr_3889.prdoc new file mode 100644 index 000000000000..b32ffcc214c0 --- /dev/null +++ b/prdoc/pr_3889.prdoc @@ -0,0 +1,14 @@ +# 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: Allow privileged virtual bond into pallet Staking + +doc: + - audience: Runtime Dev + description: | + Introduces a new low level API to allow privileged virtual bond into pallet Staking. This allows other pallets + to stake funds into staking pallet while managing the fund lock and unlocking process themselves. + +crates: + - name: pallet-staking + diff --git a/substrate/frame/nomination-pools/src/mock.rs b/substrate/frame/nomination-pools/src/mock.rs index b9301a400953..686402b84349 100644 --- a/substrate/frame/nomination-pools/src/mock.rs +++ b/substrate/frame/nomination-pools/src/mock.rs @@ -131,6 +131,10 @@ impl sp_staking::StakingInterface for StakingMock { Ok(()) } + fn update_payee(_stash: &Self::AccountId, _reward_acc: &Self::AccountId) -> DispatchResult { + unimplemented!("method currently not used in testing") + } + fn chill(_: &Self::AccountId) -> sp_runtime::DispatchResult { Ok(()) } @@ -223,6 +227,10 @@ impl sp_staking::StakingInterface for StakingMock { fn max_exposure_page_size() -> sp_staking::Page { unimplemented!("method currently not used in testing") } + + fn slash_reward_fraction() -> Perbill { + unimplemented!("method currently not used in testing") + } } #[derive_impl(frame_system::config_preludes::TestDefaultConfig)] diff --git a/substrate/frame/staking/src/ledger.rs b/substrate/frame/staking/src/ledger.rs index 9461daefed65..67a86b86226c 100644 --- a/substrate/frame/staking/src/ledger.rs +++ b/substrate/frame/staking/src/ledger.rs @@ -33,13 +33,14 @@ use frame_support::{ defensive, ensure, - traits::{Defensive, LockableCurrency, WithdrawReasons}, + traits::{Defensive, LockableCurrency}, }; use sp_staking::StakingAccount; use sp_std::prelude::*; use crate::{ - BalanceOf, Bonded, Config, Error, Ledger, Payee, RewardDestination, StakingLedger, STAKING_ID, + BalanceOf, Bonded, Config, Error, Ledger, Pallet, Payee, RewardDestination, StakingLedger, + VirtualStakers, STAKING_ID, }; #[cfg(any(feature = "runtime-benchmarks", test))] @@ -187,7 +188,17 @@ impl StakingLedger { return Err(Error::::NotStash) } - T::Currency::set_lock(STAKING_ID, &self.stash, self.total, WithdrawReasons::all()); + // We skip locking virtual stakers. + if !Pallet::::is_virtual_staker(&self.stash) { + // for direct stakers, update lock on stash based on ledger. + T::Currency::set_lock( + STAKING_ID, + &self.stash, + self.total, + frame_support::traits::WithdrawReasons::all(), + ); + } + Ledger::::insert( &self.controller().ok_or_else(|| { defensive!("update called on a ledger that is not bonded."); @@ -204,22 +215,22 @@ impl StakingLedger { /// It sets the reward preferences for the bonded stash. pub(crate) fn bond(self, payee: RewardDestination) -> Result<(), Error> { if >::contains_key(&self.stash) { - Err(Error::::AlreadyBonded) - } else { - >::insert(&self.stash, payee); - >::insert(&self.stash, &self.stash); - self.update() + return Err(Error::::AlreadyBonded) } + + >::insert(&self.stash, payee); + >::insert(&self.stash, &self.stash); + self.update() } /// Sets the ledger Payee. pub(crate) fn set_payee(self, payee: RewardDestination) -> Result<(), Error> { if !>::contains_key(&self.stash) { - Err(Error::::NotStash) - } else { - >::insert(&self.stash, payee); - Ok(()) + return Err(Error::::NotStash) } + + >::insert(&self.stash, payee); + Ok(()) } /// Sets the ledger controller to its stash. @@ -252,12 +263,16 @@ impl StakingLedger { let controller = >::get(stash).ok_or(Error::::NotStash)?; >::get(&controller).ok_or(Error::::NotController).map(|ledger| { - T::Currency::remove_lock(STAKING_ID, &ledger.stash); Ledger::::remove(controller); - >::remove(&stash); >::remove(&stash); + // kill virtual staker if it exists. + if >::take(&stash).is_none() { + // if not virtual staker, clear locks. + T::Currency::remove_lock(STAKING_ID, &ledger.stash); + } + Ok(()) })? } diff --git a/substrate/frame/staking/src/mock.rs b/substrate/frame/staking/src/mock.rs index 6db462c1a70f..b46b863c016e 100644 --- a/substrate/frame/staking/src/mock.rs +++ b/substrate/frame/staking/src/mock.rs @@ -249,17 +249,21 @@ parameter_types! { pub static LedgerSlashPerEra: (BalanceOf, BTreeMap>) = (Zero::zero(), BTreeMap::new()); + pub static SlashObserver: BTreeMap> = BTreeMap::new(); } pub struct EventListenerMock; impl OnStakingUpdate for EventListenerMock { fn on_slash( - _pool_account: &AccountId, + pool_account: &AccountId, slashed_bonded: Balance, slashed_chunks: &BTreeMap, - _total_slashed: Balance, + total_slashed: Balance, ) { LedgerSlashPerEra::set((slashed_bonded, slashed_chunks.clone())); + SlashObserver::mutate(|map| { + map.insert(*pool_account, map.get(pool_account).unwrap_or(&0) + total_slashed) + }); } } @@ -598,6 +602,21 @@ pub(crate) fn bond_nominator(who: AccountId, val: Balance, target: Vec, +) { + // In a real scenario, `who` is a keyless account managed by another pallet which provides for + // it. + System::inc_providers(&who); + + // Bond who virtually. + assert_ok!(::virtual_bond(&who, val, &payee)); + assert_ok!(Staking::nominate(RuntimeOrigin::signed(who), target)); +} + /// Progress to the given block, triggering session and era changes as we progress. /// /// This will finalize the previous block, initialize up to the given block, essentially simulating diff --git a/substrate/frame/staking/src/pallet/impls.rs b/substrate/frame/staking/src/pallet/impls.rs index 2f43e4847e45..0c0ef0dbf463 100644 --- a/substrate/frame/staking/src/pallet/impls.rs +++ b/substrate/frame/staking/src/pallet/impls.rs @@ -28,15 +28,18 @@ use frame_support::{ pallet_prelude::*, traits::{ Currency, Defensive, DefensiveSaturating, EstimateNextNewSession, Get, Imbalance, - InspectLockableCurrency, Len, OnUnbalanced, TryCollect, UnixTime, + InspectLockableCurrency, Len, LockableCurrency, OnUnbalanced, TryCollect, UnixTime, }, weights::Weight, }; use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; use pallet_session::historical; use sp_runtime::{ - traits::{Bounded, Convert, One, SaturatedConversion, Saturating, StaticLookup, Zero}, - Perbill, Percent, + traits::{ + Bounded, CheckedAdd, CheckedSub, Convert, One, SaturatedConversion, Saturating, + StaticLookup, Zero, + }, + ArithmeticError, Perbill, Percent, }; use sp_staking::{ currency_to_vote::CurrencyToVote, @@ -149,6 +152,39 @@ impl Pallet { Self::slashable_balance_of_vote_weight(who, issuance) } + pub(super) fn do_bond_extra(stash: &T::AccountId, additional: BalanceOf) -> DispatchResult { + let mut ledger = Self::ledger(StakingAccount::Stash(stash.clone()))?; + + // for virtual stakers, we don't need to check the balance. Since they are only accessed + // via low level apis, we can assume that the caller has done the due diligence. + let extra = if Self::is_virtual_staker(stash) { + additional + } else { + // additional amount or actual balance of stash whichever is lower. + additional.min( + T::Currency::free_balance(stash) + .checked_sub(&ledger.total) + .ok_or(ArithmeticError::Overflow)?, + ) + }; + + ledger.total = ledger.total.checked_add(&extra).ok_or(ArithmeticError::Overflow)?; + ledger.active = ledger.active.checked_add(&extra).ok_or(ArithmeticError::Overflow)?; + // last check: the new active amount of ledger must be more than ED. + ensure!(ledger.active >= T::Currency::minimum_balance(), Error::::InsufficientBond); + + // 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::::Bonded { stash: stash.clone(), amount: extra }); + + Ok(()) + } + pub(super) fn do_withdraw_unbonded( controller: &T::AccountId, num_slashing_spans: u32, @@ -1132,6 +1168,11 @@ impl Pallet { ) -> Exposure> { EraInfo::::get_full_exposure(era, account) } + + /// Whether `who` is a virtual staker whose funds are managed by another pallet. + pub(crate) fn is_virtual_staker(who: &T::AccountId) -> bool { + VirtualStakers::::contains_key(who) + } } impl Pallet { @@ -1748,6 +1789,23 @@ impl StakingInterface for Pallet { .map(|_| ()) } + fn update_payee(stash: &Self::AccountId, reward_acc: &Self::AccountId) -> DispatchResult { + // Since virtual stakers are not allowed to compound their rewards as this pallet does not + // manage their locks, we do not allow reward account to be set same as stash. For + // external pallets that manage the virtual bond, they can claim rewards and re-bond them. + ensure!( + !Self::is_virtual_staker(stash) || stash != reward_acc, + Error::::RewardDestinationRestricted + ); + + // since controller is deprecated and this function is never used for old ledgers with + // distinct controllers, we can safely assume that stash is the controller. + Self::set_payee( + RawOrigin::Signed(stash.clone()).into(), + RewardDestination::Account(reward_acc.clone()), + ) + } + fn chill(who: &Self::AccountId) -> DispatchResult { // defensive-only: any account bonded via this interface has the stash set as the // controller, but we have to be sure. Same comment anywhere else that we read this. @@ -1832,6 +1890,10 @@ impl StakingInterface for Pallet { } } + fn slash_reward_fraction() -> Perbill { + SlashRewardFraction::::get() + } + sp_staking::runtime_benchmarks_enabled! { fn nominations(who: &Self::AccountId) -> Option> { Nominators::::get(who).map(|n| n.targets.into_inner()) @@ -1860,6 +1922,55 @@ impl StakingInterface for Pallet { } } +impl sp_staking::StakingUnchecked for Pallet { + fn migrate_to_virtual_staker(who: &Self::AccountId) { + T::Currency::remove_lock(crate::STAKING_ID, who); + VirtualStakers::::insert(who, ()); + } + + /// Virtually bonds `keyless_who` to `payee` with `value`. + /// + /// The payee must not be the same as the `keyless_who`. + fn virtual_bond( + keyless_who: &Self::AccountId, + value: Self::Balance, + payee: &Self::AccountId, + ) -> DispatchResult { + if StakingLedger::::is_bonded(StakingAccount::Stash(keyless_who.clone())) { + return Err(Error::::AlreadyBonded.into()) + } + + // check if payee not same as who. + ensure!(keyless_who != payee, Error::::RewardDestinationRestricted); + + // mark this pallet as consumer of `who`. + frame_system::Pallet::::inc_consumers(&keyless_who).map_err(|_| Error::::BadState)?; + + // mark who as a virtual staker. + VirtualStakers::::insert(keyless_who, ()); + + Self::deposit_event(Event::::Bonded { stash: keyless_who.clone(), amount: value }); + let ledger = StakingLedger::::new(keyless_who.clone(), value); + + ledger.bond(RewardDestination::Account(payee.clone()))?; + + Ok(()) + } + + #[cfg(feature = "runtime-benchmarks")] + fn migrate_to_direct_staker(who: &Self::AccountId) { + assert!(VirtualStakers::::contains_key(who)); + let ledger = StakingLedger::::get(Stash(who.clone())).unwrap(); + T::Currency::set_lock( + crate::STAKING_ID, + who, + ledger.total, + frame_support::traits::WithdrawReasons::all(), + ); + VirtualStakers::::remove(who); + } +} + #[cfg(any(test, feature = "try-runtime"))] impl Pallet { pub(crate) fn do_try_state(_: BlockNumberFor) -> Result<(), TryRuntimeError> { @@ -1980,16 +2091,44 @@ impl Pallet { /// Invariants: /// * Stake consistency: ledger.total == ledger.active + sum(ledger.unlocking). /// * The ledger's controller and stash matches the associated `Bonded` tuple. - /// * Staking locked funds for every bonded stash should be the same as its ledger's total. + /// * Staking locked funds for every bonded stash (non virtual stakers) should be the same as + /// its ledger's total. + /// * For virtual stakers, locked funds should be zero and payee should be non-stash account. /// * Staking ledger and bond are not corrupted. fn check_ledgers() -> Result<(), TryRuntimeError> { Bonded::::iter() .map(|(stash, ctrl)| { // ensure locks consistency. - ensure!( - Self::inspect_bond_state(&stash) == Ok(LedgerIntegrityState::Ok), - "bond, ledger and/or staking lock inconsistent for a bonded stash." - ); + if VirtualStakers::::contains_key(stash.clone()) { + ensure!( + T::Currency::balance_locked(crate::STAKING_ID, &stash) == Zero::zero(), + "virtual stakers should not have any locked balance" + ); + ensure!( + >::get(stash.clone()).unwrap() == stash.clone(), + "stash and controller should be same" + ); + ensure!( + Ledger::::get(stash.clone()).unwrap().stash == stash, + "ledger corrupted for virtual staker" + ); + let reward_destination = >::get(stash.clone()).unwrap(); + if let RewardDestination::Account(payee) = reward_destination { + ensure!( + payee != stash.clone(), + "reward destination should not be same as stash for virtual staker" + ); + } else { + return Err(DispatchError::Other( + "reward destination must be of account variant for virtual staker", + )); + } + } else { + ensure!( + Self::inspect_bond_state(&stash) == Ok(LedgerIntegrityState::Ok), + "bond, ledger and/or staking lock inconsistent for a bonded stash." + ); + } // ensure ledger consistency. Self::ensure_ledger_consistent(ctrl) diff --git a/substrate/frame/staking/src/pallet/mod.rs b/substrate/frame/staking/src/pallet/mod.rs index 2e5b3aa7b873..76ddad6f1359 100644 --- a/substrate/frame/staking/src/pallet/mod.rs +++ b/substrate/frame/staking/src/pallet/mod.rs @@ -32,7 +32,7 @@ use frame_support::{ }; use frame_system::{ensure_root, ensure_signed, pallet_prelude::*}; use sp_runtime::{ - traits::{CheckedSub, SaturatedConversion, StaticLookup, Zero}, + traits::{SaturatedConversion, StaticLookup, Zero}, ArithmeticError, Perbill, Percent, }; @@ -379,6 +379,15 @@ pub mod pallet { pub type Nominators = CountedStorageMap<_, Twox64Concat, T::AccountId, Nominations>; + /// Stakers whose funds are managed by other pallets. + /// + /// This pallet does not apply any locks on them, therefore they are only virtually bonded. They + /// are expected to be keyless accounts and hence should not be allowed to mutate their ledger + /// directly via this pallet. Instead, these accounts are managed by other pallets and accessed + /// via low level apis. We keep track of them to do minimal integrity checks. + #[pallet::storage] + pub type VirtualStakers = CountedStorageMap<_, Twox64Concat, T::AccountId, ()>; + /// The maximum nominator count before we stop allowing new validators to join. /// /// When this value is not set, no limits are enforced. @@ -858,6 +867,10 @@ pub mod pallet { ControllerDeprecated, /// Cannot reset a ledger. CannotRestoreLedger, + /// Provided reward destination is not allowed. + RewardDestinationRestricted, + /// Not enough funds available to withdraw. + NotEnoughFunds, } #[pallet::hooks] @@ -985,29 +998,7 @@ pub mod pallet { #[pallet::compact] max_additional: BalanceOf, ) -> DispatchResult { let stash = ensure_signed(origin)?; - let mut ledger = Self::ledger(StakingAccount::Stash(stash.clone()))?; - - let stash_balance = T::Currency::free_balance(&stash); - if let Some(extra) = stash_balance.checked_sub(&ledger.total) { - let extra = extra.min(max_additional); - ledger.total += extra; - ledger.active += extra; - // Last check: the new active amount of ledger must be more than ED. - ensure!( - ledger.active >= T::Currency::minimum_balance(), - Error::::InsufficientBond - ); - - // 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::::Bonded { stash, amount: extra }); - } - Ok(()) + Self::do_bond_extra(&stash, max_additional) } /// Schedule a portion of the stash to be unlocked ready for transfer out after the bond diff --git a/substrate/frame/staking/src/slashing.rs b/substrate/frame/staking/src/slashing.rs index 709fd1441ec3..2011e9eb8301 100644 --- a/substrate/frame/staking/src/slashing.rs +++ b/substrate/frame/staking/src/slashing.rs @@ -609,8 +609,13 @@ pub fn do_slash( }; let value = ledger.slash(value, T::Currency::minimum_balance(), slash_era); + if value.is_zero() { + // nothing to do + return + } - if !value.is_zero() { + // Skip slashing for virtual stakers. The pallets managing them should handle the slashing. + if !Pallet::::is_virtual_staker(stash) { let (imbalance, missing) = T::Currency::slash(stash, value); slashed_imbalance.subsume(imbalance); @@ -618,17 +623,14 @@ pub fn do_slash( // deduct overslash from the reward payout *reward_payout = reward_payout.saturating_sub(missing); } + } - let _ = ledger - .update() - .defensive_proof("ledger fetched from storage so it exists in storage; qed."); + let _ = ledger + .update() + .defensive_proof("ledger fetched from storage so it exists in storage; qed."); - // trigger the event - >::deposit_event(super::Event::::Slashed { - staker: stash.clone(), - amount: value, - }); - } + // trigger the event + >::deposit_event(super::Event::::Slashed { staker: stash.clone(), amount: value }); } /// Apply a previously-unapplied slash. diff --git a/substrate/frame/staking/src/tests.rs b/substrate/frame/staking/src/tests.rs index a5c9abe2f176..87f6fd424bd7 100644 --- a/substrate/frame/staking/src/tests.rs +++ b/substrate/frame/staking/src/tests.rs @@ -27,7 +27,7 @@ use frame_support::{ assert_noop, assert_ok, assert_storage_noop, dispatch::{extract_actual_weight, GetDispatchInfo, WithPostDispatchInfo}, pallet_prelude::*, - traits::{Currency, Get, ReservableCurrency}, + traits::{Currency, Get, InspectLockableCurrency, ReservableCurrency}, }; use mock::*; @@ -623,12 +623,8 @@ fn nominating_and_rewards_should_work() { )); assert_ok!(Staking::nominate(RuntimeOrigin::signed(1), vec![11, 21, 31])); - assert_ok!(Staking::bond( - RuntimeOrigin::signed(3), - 1000, - RewardDestination::Account(3) - )); - assert_ok!(Staking::nominate(RuntimeOrigin::signed(3), vec![11, 21, 41])); + // the second nominator is virtual. + bond_virtual_nominator(3, 333, 1000, vec![11, 21, 41]); // the total reward for era 0 let total_payout_0 = current_total_payout_for_duration(reward_time_per_era()); @@ -694,10 +690,12 @@ fn nominating_and_rewards_should_work() { ); // Nominator 3: has [400/1800 ~ 2/9 from 10] + [600/2200 ~ 3/11 from 21]'s reward. ==> // 2/9 + 3/11 + assert_eq!(Balances::total_balance(&3), initial_balance); + // 333 is the reward destination for 3. assert_eq_error_rate!( - Balances::total_balance(&3), - initial_balance + (2 * payout_for_11 / 9 + 3 * payout_for_21 / 11), - 2, + Balances::total_balance(&333), + 2 * payout_for_11 / 9 + 3 * payout_for_21 / 11, + 2 ); // Validator 11: got 800 / 1800 external stake => 8/18 =? 4/9 => Validator's share = 5/9 @@ -1893,7 +1891,7 @@ fn reap_stash_works() { .balance_factor(10) .build_and_execute(|| { // given - assert_eq!(Balances::free_balance(11), 10 * 1000); + assert_eq!(Balances::balance_locked(STAKING_ID, &11), 10 * 1000); assert_eq!(Staking::bonded(&11), Some(11)); assert!(>::contains_key(&11)); @@ -1919,6 +1917,8 @@ fn reap_stash_works() { assert!(!>::contains_key(&11)); assert!(!>::contains_key(&11)); assert!(!>::contains_key(&11)); + // lock is removed. + assert_eq!(Balances::balance_locked(STAKING_ID, &11), 0); }); } @@ -6849,6 +6849,226 @@ mod staking_interface { } } +mod staking_unchecked { + use sp_staking::{Stake, StakingInterface, StakingUnchecked}; + + use super::*; + + #[test] + fn virtual_bond_does_not_lock() { + ExtBuilder::default().build_and_execute(|| { + mock::start_active_era(1); + assert_eq!(Balances::free_balance(10), 1); + // 10 can bond more than its balance amount since we do not require lock for virtual + // bonding. + assert_ok!(::virtual_bond(&10, 100, &15)); + // nothing is locked on 10. + assert_eq!(Balances::balance_locked(STAKING_ID, &10), 0); + // adding more balance does not lock anything as well. + assert_ok!(::bond_extra(&10, 1000)); + // but ledger is updated correctly. + assert_eq!( + ::stake(&10), + Ok(Stake { total: 1100, active: 1100 }) + ); + + // lets try unbonding some amount. + assert_ok!(::unbond(&10, 200)); + assert_eq!( + Staking::ledger(10.into()).unwrap(), + StakingLedgerInspect { + stash: 10, + total: 1100, + active: 1100 - 200, + unlocking: bounded_vec![UnlockChunk { value: 200, era: 1 + 3 }], + legacy_claimed_rewards: bounded_vec![], + } + ); + + assert_eq!( + ::stake(&10), + Ok(Stake { total: 1100, active: 900 }) + ); + // still no locks. + assert_eq!(Balances::balance_locked(STAKING_ID, &10), 0); + + mock::start_active_era(2); + // cannot withdraw without waiting for unbonding period. + assert_ok!(::withdraw_unbonded(10, 0)); + assert_eq!( + ::stake(&10), + Ok(Stake { total: 1100, active: 900 }) + ); + + // in era 4, 10 can withdraw unlocking amount. + mock::start_active_era(4); + assert_ok!(::withdraw_unbonded(10, 0)); + assert_eq!( + ::stake(&10), + Ok(Stake { total: 900, active: 900 }) + ); + + // unbond all. + assert_ok!(::unbond(&10, 900)); + assert_eq!( + ::stake(&10), + Ok(Stake { total: 900, active: 0 }) + ); + mock::start_active_era(7); + assert_ok!(::withdraw_unbonded(10, 0)); + + // ensure withdrawing all amount cleans up storage. + assert_eq!(Staking::ledger(10.into()), Err(Error::::NotStash)); + assert_eq!(VirtualStakers::::contains_key(10), false); + }) + } + + #[test] + fn virtual_staker_cannot_pay_reward_to_self_account() { + ExtBuilder::default().build_and_execute(|| { + // cannot set payee to self + assert_noop!( + ::virtual_bond(&10, 100, &10), + Error::::RewardDestinationRestricted + ); + + // to another account works + assert_ok!(::virtual_bond(&10, 100, &11)); + + // cannot set via set_payee as well. + assert_noop!( + ::update_payee(&10, &10), + Error::::RewardDestinationRestricted + ); + }); + } + + #[test] + fn virtual_staker_cannot_bond_again() { + ExtBuilder::default().build_and_execute(|| { + // 200 virtual bonds + bond_virtual_nominator(200, 201, 500, vec![11, 21]); + + // Tries bonding again + assert_noop!( + ::virtual_bond(&200, 200, &201), + Error::::AlreadyBonded + ); + + // And again with a different reward destination. + assert_noop!( + ::virtual_bond(&200, 200, &202), + Error::::AlreadyBonded + ); + + // Direct bond is not allowed as well. + assert_noop!( + ::bond(&200, 200, &202), + Error::::AlreadyBonded + ); + }); + } + + #[test] + fn normal_staker_cannot_virtual_bond() { + ExtBuilder::default().build_and_execute(|| { + // 101 is a nominator trying to virtual bond + assert_noop!( + ::virtual_bond(&101, 200, &102), + Error::::AlreadyBonded + ); + + // validator 21 tries to virtual bond + assert_noop!( + ::virtual_bond(&21, 200, &22), + Error::::AlreadyBonded + ); + }); + } + + #[test] + fn migrate_virtual_staker() { + ExtBuilder::default().build_and_execute(|| { + // give some balance to 200 + Balances::make_free_balance_be(&200, 2000); + + // stake + assert_ok!(Staking::bond(RuntimeOrigin::signed(200), 1000, RewardDestination::Staked)); + assert_eq!(Balances::balance_locked(crate::STAKING_ID, &200), 1000); + + // migrate them to virtual staker + ::migrate_to_virtual_staker(&200); + // payee needs to be updated to a non-stash account. + assert_ok!(::update_payee(&200, &201)); + + // ensure the balance is not locked anymore + assert_eq!(Balances::balance_locked(crate::STAKING_ID, &200), 0); + + // and they are marked as virtual stakers + assert_eq!(Pallet::::is_virtual_staker(&200), true); + }); + } + + #[test] + fn virtual_nominators_are_lazily_slashed() { + ExtBuilder::default().build_and_execute(|| { + mock::start_active_era(1); + let slash_percent = Perbill::from_percent(5); + let initial_exposure = Staking::eras_stakers(active_era(), &11); + // 101 is a nominator for 11 + assert_eq!(initial_exposure.others.first().unwrap().who, 101); + // make 101 a virtual nominator + ::migrate_to_virtual_staker(&101); + // set payee different to self. + assert_ok!(::update_payee(&101, &102)); + + // cache values + let nominator_stake = Staking::ledger(101.into()).unwrap().active; + let nominator_balance = balances(&101).0; + let validator_stake = Staking::ledger(11.into()).unwrap().active; + let validator_balance = balances(&11).0; + let exposed_stake = initial_exposure.total; + let exposed_validator = initial_exposure.own; + let exposed_nominator = initial_exposure.others.first().unwrap().value; + + // 11 goes offline + on_offence_now( + &[OffenceDetails { offender: (11, initial_exposure.clone()), reporters: vec![] }], + &[slash_percent], + ); + + let slash_amount = slash_percent * exposed_stake; + let validator_share = + Perbill::from_rational(exposed_validator, exposed_stake) * slash_amount; + let nominator_share = + Perbill::from_rational(exposed_nominator, exposed_stake) * slash_amount; + + // both slash amounts need to be positive for the test to make sense. + assert!(validator_share > 0); + assert!(nominator_share > 0); + + // both stakes must have been decreased pro-rata. + assert_eq!( + Staking::ledger(101.into()).unwrap().active, + nominator_stake - nominator_share + ); + assert_eq!( + Staking::ledger(11.into()).unwrap().active, + validator_stake - validator_share + ); + + // validator balance is slashed as usual + assert_eq!(balances(&11).0, validator_balance - validator_share); + // Because slashing happened. + assert!(is_disabled(11)); + + // but virtual nominator's balance is not slashed. + assert_eq!(Balances::free_balance(&101), nominator_balance); + // but slash is broadcasted to slash observers. + assert_eq!(SlashObserver::get().get(&101).unwrap(), &nominator_share); + }) + } +} mod ledger { use super::*; @@ -7327,7 +7547,6 @@ mod ledger { mod ledger_recovery { use super::*; - use frame_support::traits::InspectLockableCurrency; #[test] fn inspect_recovery_ledger_simple_works() { diff --git a/substrate/primitives/staking/src/lib.rs b/substrate/primitives/staking/src/lib.rs index 11b7ef41b9a7..ad6cc6e2f4ff 100644 --- a/substrate/primitives/staking/src/lib.rs +++ b/substrate/primitives/staking/src/lib.rs @@ -29,7 +29,7 @@ use core::ops::Sub; use scale_info::TypeInfo; use sp_runtime::{ traits::{AtLeast32BitUnsigned, Zero}, - DispatchError, DispatchResult, RuntimeDebug, Saturating, + DispatchError, DispatchResult, Perbill, RuntimeDebug, Saturating, }; pub mod offence; @@ -254,6 +254,9 @@ pub trait StakingInterface { /// schedules have reached their unlocking era should allow more calls to this function. fn unbond(stash: &Self::AccountId, value: Self::Balance) -> DispatchResult; + /// Update the reward destination for the ledger associated with the stash. + fn update_payee(stash: &Self::AccountId, reward_acc: &Self::AccountId) -> DispatchResult; + /// Unlock any funds schedule to unlock before or at the current era. /// /// Returns whether the stash was killed because of this withdraw or not. @@ -274,7 +277,7 @@ pub trait StakingInterface { /// Checks whether an account `staker` has been exposed in an era. fn is_exposed_in_era(who: &Self::AccountId, era: &EraIndex) -> bool; - /// Return the status of the given staker, `None` if not staked at all. + /// Return the status of the given staker, `Err` if not staked at all. fn status(who: &Self::AccountId) -> Result, DispatchError>; /// Checks whether or not this is a validator account. @@ -290,6 +293,9 @@ pub trait StakingInterface { } } + /// Returns the fraction of the slash to be rewarded to reporter. + fn slash_reward_fraction() -> Perbill; + #[cfg(feature = "runtime-benchmarks")] fn max_exposure_page_size() -> Page; @@ -304,6 +310,34 @@ pub trait StakingInterface { fn set_current_era(era: EraIndex); } +/// Set of low level apis to manipulate staking ledger. +/// +/// These apis bypass some or all safety checks and should only be used if you know what you are +/// doing. +pub trait StakingUnchecked: StakingInterface { + /// Migrate an existing staker to a virtual staker. + /// + /// It would release all funds held by the implementation pallet. + fn migrate_to_virtual_staker(who: &Self::AccountId); + + /// Book-keep a new bond for `keyless_who` without applying any locks (hence virtual). + /// + /// It is important that `keyless_who` is a keyless account and therefore cannot interact with + /// staking pallet directly. Caller is responsible for ensuring the passed amount is locked and + /// valid. + fn virtual_bond( + keyless_who: &Self::AccountId, + value: Self::Balance, + payee: &Self::AccountId, + ) -> DispatchResult; + + /// Migrate a virtual staker to a direct staker. + /// + /// Only used for testing. + #[cfg(feature = "runtime-benchmarks")] + fn migrate_to_direct_staker(who: &Self::AccountId); +} + /// The amount of exposure for an era that an individual nominator has (susceptible to slashing). #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Encode, Decode, RuntimeDebug, TypeInfo)] pub struct IndividualExposure {