diff --git a/Cargo.lock b/Cargo.lock index 413bd28abe03..a87d03b5b1c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9698,6 +9698,29 @@ dependencies = [ "sp-std 14.0.0", ] +[[package]] +name = "pallet-delegated-staking" +version = "4.0.0-dev" +dependencies = [ + "frame-election-provider-support", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-nomination-pools", + "pallet-staking", + "pallet-staking-reward-curve", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-staking", + "sp-std 14.0.0", + "sp-tracing 16.0.0", + "substrate-test-utils", +] + [[package]] name = "pallet-democracy" version = "28.0.0" @@ -19111,6 +19134,7 @@ dependencies = [ "serde", "sp-core", "sp-runtime", + "sp-std 14.0.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e6162830375f..2966e45fbec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -385,6 +385,7 @@ members = [ "substrate/frame/staking/reward-curve", "substrate/frame/staking/reward-fn", "substrate/frame/staking/runtime-api", + "substrate/frame/delegated-staking", "substrate/frame/state-trie-migration", "substrate/frame/statement", "substrate/frame/sudo", diff --git a/substrate/frame/delegated-staking/Cargo.toml b/substrate/frame/delegated-staking/Cargo.toml new file mode 100644 index 000000000000..b4b9768256c6 --- /dev/null +++ b/substrate/frame/delegated-staking/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "pallet-delegated-staking" +version = "4.0.0-dev" +authors.workspace = true +edition.workspace = true +license = "Apache-2.0" +homepage = "https://substrate.io" +repository.workspace = true +description = "FRAME delegated staking pallet" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.2.2", default-features = false, features = ["derive"] } +frame-support = { path = "../support", default-features = false} +frame-system = { path = "../system", default-features = false} +scale-info = { version = "2.10.0", default-features = false, features = ["derive"] } +sp-std = { path = "../../primitives/std", default-features = false} +sp-runtime = { path = "../../primitives/runtime", default-features = false} +sp-staking = { path = "../../primitives/staking", default-features = false } + +[dev-dependencies] +sp-core = { path = "../../primitives/core" } +sp-io = { path = "../../primitives/io" } +substrate-test-utils = { path = "../../test-utils" } +sp-tracing = { path = "../../primitives/tracing" } +pallet-staking = { path = "../staking" } +pallet-nomination-pools = { path = "../nomination-pools" } +pallet-balances = { path = "../balances" } +pallet-timestamp = { path = "../timestamp" } +pallet-staking-reward-curve = { path = "../staking/reward-curve" } +frame-election-provider-support = { path = "../election-provider-support", default-features = false} + +[features] +default = [ "std" ] +std = [ + "codec/std", + "frame-support/std", + "frame-system/std", + "scale-info/std", + "sp-core/std", + "sp-io/std", + "sp-std/std", + "sp-runtime/std", + "sp-staking/std", + "pallet-balances/std", + "pallet-staking/std", + "pallet-timestamp/std", + "frame-election-provider-support/std", +] +runtime-benchmarks = [ + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "sp-staking/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "pallet-staking/runtime-benchmarks", + "pallet-timestamp/runtime-benchmarks", + "frame-election-provider-support/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "sp-runtime/try-runtime", + "pallet-balances/try-runtime", + "pallet-staking/try-runtime", + "pallet-timestamp/try-runtime", + "frame-election-provider-support/try-runtime", +] diff --git a/substrate/frame/delegated-staking/src/benchmarking.rs b/substrate/frame/delegated-staking/src/benchmarking.rs new file mode 100644 index 000000000000..808d19a5ce9a --- /dev/null +++ b/substrate/frame/delegated-staking/src/benchmarking.rs @@ -0,0 +1,20 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Benchmarking for pallet-delegated-staking. + +#![cfg(feature = "runtime-benchmarks")] diff --git a/substrate/frame/delegated-staking/src/impls.rs b/substrate/frame/delegated-staking/src/impls.rs new file mode 100644 index 000000000000..e256b917a94b --- /dev/null +++ b/substrate/frame/delegated-staking/src/impls.rs @@ -0,0 +1,288 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Implementations of public traits, namely [StakingInterface], [DelegatedStakeInterface] and +//! [OnStakingUpdate]. + +use super::*; +use sp_staking::{delegation::DelegatedStakeInterface, OnStakingUpdate}; + +/// StakingInterface implementation with delegation support. +/// +/// Only supports Nominators via Delegated Bonds. It is possible for a nominator to migrate and +/// become a `delegatee`. +impl StakingInterface for Pallet { + type Balance = BalanceOf; + type AccountId = T::AccountId; + type CurrencyToVote = ::CurrencyToVote; + + fn minimum_nominator_bond() -> Self::Balance { + T::CoreStaking::minimum_nominator_bond() + } + + fn minimum_validator_bond() -> Self::Balance { + T::CoreStaking::minimum_validator_bond() + } + + fn stash_by_ctrl(_controller: &Self::AccountId) -> Result { + // ctrl are deprecated, just return err. + Err(Error::::NotSupported.into()) + } + + fn bonding_duration() -> EraIndex { + T::CoreStaking::bonding_duration() + } + + fn current_era() -> EraIndex { + T::CoreStaking::current_era() + } + + fn stake(who: &Self::AccountId) -> Result, DispatchError> { + ensure!(Self::is_delegatee(who), Error::::NotSupported); + return T::CoreStaking::stake(who); + } + + fn total_stake(who: &Self::AccountId) -> Result { + if Self::is_delegatee(who) { + return T::CoreStaking::total_stake(who); + } + + if Self::is_delegator(who) { + let delegation = Delegation::::get(who).defensive_ok_or(Error::::BadState)?; + return Ok(delegation.amount); + } + + Err(Error::::NotSupported.into()) + } + + fn active_stake(who: &Self::AccountId) -> Result { + T::CoreStaking::active_stake(who) + } + + fn is_unbonding(who: &Self::AccountId) -> Result { + T::CoreStaking::is_unbonding(who) + } + + fn fully_unbond(who: &Self::AccountId) -> DispatchResult { + ensure!(Self::is_delegatee(who), Error::::NotSupported); + return T::CoreStaking::fully_unbond(who); + } + + fn bond( + who: &Self::AccountId, + value: Self::Balance, + payee: &Self::AccountId, + ) -> DispatchResult { + // ensure who is not already staked + ensure!(T::CoreStaking::status(who).is_err(), Error::::AlreadyStaking); + let delegatee = Delegatee::::from(who)?; + + ensure!(delegatee.available_to_bond() >= value, Error::::NotEnoughFunds); + ensure!(delegatee.ledger.payee == *payee, Error::::InvalidRewardDestination); + + T::CoreStaking::virtual_bond(who, value, payee) + } + + fn nominate(who: &Self::AccountId, validators: Vec) -> DispatchResult { + ensure!(Self::is_delegatee(who), Error::::NotDelegatee); + return T::CoreStaking::nominate(who, validators); + } + + fn chill(who: &Self::AccountId) -> DispatchResult { + ensure!(Self::is_delegatee(who), Error::::NotDelegatee); + return T::CoreStaking::chill(who); + } + + fn bond_extra(who: &Self::AccountId, extra: Self::Balance) -> DispatchResult { + let ledger = >::get(who).ok_or(Error::::NotDelegatee)?; + ensure!(ledger.stakeable_balance() >= extra, Error::::NotEnoughFunds); + + T::CoreStaking::bond_extra(who, extra) + } + + fn unbond(stash: &Self::AccountId, value: Self::Balance) -> DispatchResult { + let delegatee = Delegatee::::from(stash)?; + ensure!(delegatee.bonded_stake() >= value, Error::::NotEnoughFunds); + + T::CoreStaking::unbond(stash, value) + } + + fn update_payee(stash: &Self::AccountId, reward_acc: &Self::AccountId) -> DispatchResult { + T::CoreStaking::update_payee(stash, reward_acc) + } + + /// Withdraw unbonding funds until current era. + /// + /// Funds are moved to unclaimed_withdrawals register of the `DelegateeLedger`. + fn withdraw_unbonded( + delegatee_acc: Self::AccountId, + num_slashing_spans: u32, + ) -> Result { + Pallet::::withdraw_unbonded(&delegatee_acc, num_slashing_spans) + .map(|delegatee| delegatee.ledger.total_delegated.is_zero()) + } + + fn desired_validator_count() -> u32 { + T::CoreStaking::desired_validator_count() + } + + fn election_ongoing() -> bool { + T::CoreStaking::election_ongoing() + } + + fn force_unstake(_who: Self::AccountId) -> DispatchResult { + Err(Error::::NotSupported.into()) + } + + fn is_exposed_in_era(who: &Self::AccountId, era: &EraIndex) -> bool { + T::CoreStaking::is_exposed_in_era(who, era) + } + + fn status(who: &Self::AccountId) -> Result, DispatchError> { + ensure!(Self::is_delegatee(who), Error::::NotDelegatee); + T::CoreStaking::status(who) + } + + fn is_validator(who: &Self::AccountId) -> bool { + T::CoreStaking::is_validator(who) + } + + fn nominations(who: &Self::AccountId) -> Option> { + T::CoreStaking::nominations(who) + } + + fn slash_reward_fraction() -> Perbill { + T::CoreStaking::slash_reward_fraction() + } + + #[cfg(feature = "runtime-benchmarks")] + fn max_exposure_page_size() -> sp_staking::Page { + T::CoreStaking::max_exposure_page_size() + } + + #[cfg(feature = "runtime-benchmarks")] + fn add_era_stakers( + current_era: &EraIndex, + stash: &Self::AccountId, + exposures: Vec<(Self::AccountId, Self::Balance)>, + ) { + T::CoreStaking::add_era_stakers(current_era, stash, exposures) + } + + #[cfg(feature = "runtime-benchmarks")] + fn set_current_era(era: EraIndex) { + T::CoreStaking::set_current_era(era) + } +} + +impl DelegatedStakeInterface for Pallet { + /// Effective balance of the delegatee account. + fn delegatee_balance(who: &Self::AccountId) -> Self::Balance { + Delegatee::::from(who) + .map(|delegatee| delegatee.ledger.effective_balance()) + .unwrap_or_default() + } + + fn delegator_balance(delegator: &Self::AccountId) -> Self::Balance { + Delegation::::get(delegator).map(|d| d.amount).unwrap_or_default() + } + + /// Delegate funds to `Delegatee`. + fn delegate( + who: &Self::AccountId, + delegatee: &Self::AccountId, + reward_account: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult { + Pallet::::register_as_delegatee( + RawOrigin::Signed(delegatee.clone()).into(), + reward_account.clone(), + )?; + + // Delegate the funds from who to the delegatee account. + Pallet::::delegate_funds( + RawOrigin::Signed(who.clone()).into(), + delegatee.clone(), + amount, + ) + } + + /// Add more delegation to the delegatee account. + fn delegate_extra( + who: &Self::AccountId, + delegatee: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult { + Pallet::::delegate_funds( + RawOrigin::Signed(who.clone()).into(), + delegatee.clone(), + amount, + ) + } + + /// Withdraw delegation of `delegator` to `delegatee`. + /// + /// If there are funds in `delegatee` account that can be withdrawn, then those funds would be + /// unlocked/released in the delegator's account. + fn withdraw_delegation( + delegator: &Self::AccountId, + delegatee: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult { + // fixme(ank4n): Can this not require slashing spans? + Pallet::::release( + RawOrigin::Signed(delegatee.clone()).into(), + delegator.clone(), + amount, + 0, + ) + } + + /// Returns true if the `delegatee` have any slash pending to be applied. + fn has_pending_slash(delegatee: &Self::AccountId) -> bool { + Delegatee::::from(delegatee) + .map(|d| !d.ledger.pending_slash.is_zero()) + .unwrap_or(false) + } + + fn delegator_slash( + delegatee: &Self::AccountId, + delegator: &Self::AccountId, + value: Self::Balance, + maybe_reporter: Option, + ) -> sp_runtime::DispatchResult { + Pallet::::do_slash(delegatee.clone(), delegator.clone(), value, maybe_reporter) + } +} + +impl OnStakingUpdate> for Pallet { + fn on_slash( + who: &T::AccountId, + _slashed_active: BalanceOf, + _slashed_unlocking: &sp_std::collections::btree_map::BTreeMap>, + slashed_total: BalanceOf, + ) { + >::mutate(who, |maybe_register| match maybe_register { + // if delegatee, register the slashed amount as pending slash. + Some(register) => register.pending_slash.saturating_accrue(slashed_total), + None => { + // nothing to do + }, + }); + } +} diff --git a/substrate/frame/delegated-staking/src/lib.rs b/substrate/frame/delegated-staking/src/lib.rs new file mode 100644 index 000000000000..3b49ff477f27 --- /dev/null +++ b/substrate/frame/delegated-staking/src/lib.rs @@ -0,0 +1,827 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! # Delegated Staking Pallet +//! +//! An abstraction over staking pallet to support delegation of funds to a `delegatee` account which +//! can use all the delegated funds to it in the staking pallet as if its own fund. +//! +//! NOTE: The pallet exposes some dispatchable calls already, but they might not be fully usable +//! from outside the runtime. In the current version, the pallet is meant to be used by other +//! pallets in the same runtime. Eventually though, expect those calls to be functionally complete +//! and usable by off-chain programs as well as xcm based multi locations. +//! +//! Declaring dispatchable still has the benefit of being transactable for unit tests as well as +//! aligned with general direction of moving towards a permissionless pallet. For example, we could +//! clearly signal who is the expected signer of any interaction with this pallet and take into +//! account any security considerations associated with those interactions. +//! +//! ## Goals +//! +//! Direct nomination on the Staking pallet does not scale well. Nominations pools were created to +//! address this by pooling delegator funds into one account and then staking it. This though had +//! a very critical limitation that the funds were moved from delegator account to pool account +//! and hence the delegator lost control over their funds for using it for other purposes such as +//! governance. This pallet aims to solve this by extending the staking pallet to support a new +//! primitive function: delegation of funds to an account for the intent of staking. +//! +//! #### Reward and Slashing +//! This pallet does not enforce any specific strategy for how rewards or slashes are applied. It +//! is upto the `delegatee` account to decide how to apply the rewards and slashes. +//! +//! This importantly allows clients of this pallet to build their own strategies for reward/slashes. +//! For example, a `delegatee` account can choose to first slash the reward pot before slashing the +//! delegators. Or part of the reward can go to a insurance fund that can be used to cover any +//! potential future slashes. The goal is to eventually allow foreign MultiLocations +//! (smart contracts or pallets on another chain) to build their own pooled staking solutions +//! similar to `NominationPools`. +//! +//! ## Key Terminologies +//! - **Delegatee**: An account who accepts delegations from other accounts. +//! - **Delegator**: An account who delegates their funds to a `delegatee`. +//! - **DelegateeLedger**: A data structure that stores important information about the `delegatee` +//! such as their total delegated stake. +//! - **Delegation**: A data structure that stores the amount of funds delegated to a `delegatee` by +//! a `delegator`. +//! +//! ## Interface +//! +//! #### Dispatchable Calls +//! The pallet exposes the following [`Call`]s: +//! - `register_as_delegatee`: Register an account to be a `delegatee`. Once an account is +//! registered as a `delegatee`, for staking operations, only its delegated funds are used. This +//! means it cannot use its own free balance to stake. +//! - `migrate_to_delegate`: This allows a `Nominator` account to become a `delegatee` account. +//! Explained in more detail in the `Migration` section. +//! - `release`: Release funds to `delegator` from `unclaimed_withdrawals` register of the +//! `delegatee`. +//! - `migrate_delegation`: Migrate delegated funds from one account to another. This is useful for +//! example, delegators to a pool account which has migrated to be `delegatee` to migrate their +//! funds from pool account back to their own account and delegated to pool as a `delegator`. Once +//! the funds are migrated, the `delegator` can use the funds for other purposes which allows +//! usage of held funds in an account, such as governance. +//! - `delegate_funds`: Delegate funds to a `delegatee` account and update the bond to staking. +//! - `apply_slash`: If there is a pending slash in `delegatee` ledger, the passed delegator's +//! balance is slashed by the amount and the slash is removed from the delegatee ledger. +//! +//! #### [Staking Interface](StakingInterface) +//! This pallet reimplements the staking interface as a wrapper implementation over +//! [Config::CoreStaking] to provide delegation based staking. NominationPool can use this pallet as +//! its Staking provider to support delegation based staking from pool accounts. +//! +//! ## Lazy Slashing +//! One of the reasons why direct nominators on staking pallet cannot scale well is because all +//! nominators are slashed at the same time. This is expensive and needs to be bounded operation. +//! +//! This pallet implements a lazy slashing mechanism. Any slashes to a `delegatee` are posted in its +//! `DelegateeLedger` as a pending slash. Since the actual amount is held in the multiple +//! `delegator` accounts, this pallet has no way to know how to apply slash. It is `delegatee`'s +//! responsibility to apply slashes for each delegator, one at a time. Staking pallet ensures the +//! pending slash never exceeds staked amount and would freeze further withdraws until pending +//! slashes are applied. +//! +//! The user of this pallet can apply slash using +//! [StakingInterface::delegator_slash](sp_staking::StakingInterface::delegator_slash). +//! +//! ## Migration from Nominator to Delegatee +//! More details [here](https://hackmd.io/@ak0n/np-delegated-staking-migration). +//! +//! ## Nomination Pool vs Delegation Staking +//! This pallet is not a replacement for Nomination Pool but adds a new primitive over staking +//! pallet that can be used by Nomination Pool to support delegation based staking. It can be +//! thought of as something in middle of Nomination Pool and Staking Pallet. Technically, these +//! changes could be made in one of those pallets as well but that would have meant significant +//! refactoring and high chances of introducing a regression. With this approach, we can keep the +//! existing pallets with minimal changes and introduce a new pallet that can be optionally used by +//! Nomination Pool. This is completely configurable and a runtime can choose whether to use +//! this pallet or not. +//! +//! With that said, following is the main difference between +//! #### Nomination Pool without delegation support +//! 1) transfer fund from delegator to pool account, and +//! 2) stake from pool account as a direct nominator. +//! +//! #### Nomination Pool with delegation support +//! 1) delegate fund from delegator to pool account, and +//! 2) stake from pool account as a `Delegatee` account on the staking pallet. +//! +//! The difference being, in the second approach, the delegated funds will be locked in-place in +//! user's account enabling them to participate in use cases that allows use of `held` funds such +//! as participation in governance voting. +//! +//! Nomination pool still does all the heavy lifting around pool administration, reward +//! distribution, lazy slashing and as such, is not meant to be replaced with this pallet. +//! +//! ## Limitations +//! - Rewards can not be auto-compounded. +//! - Slashes are lazy and hence there could be a period of time when an account can use funds for +//! operations such as voting in governance even though they should be slashed. + +#![cfg_attr(not(feature = "std"), no_std)] +#![deny(rustdoc::broken_intra_doc_links)] + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +pub use pallet::*; + +mod types; + +use types::*; + +mod impls; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{ + hold::{ + Balanced as FunHoldBalanced, Inspect as FunHoldInspect, Mutate as FunHoldMutate, + }, + Balanced, Inspect as FunInspect, Mutate as FunMutate, + }, + tokens::{fungible::Credit, Fortitude, Precision, Preservation}, + Defensive, DefensiveOption, Imbalance, OnUnbalanced, + }, + weights::Weight, +}; + +use sp_runtime::{ + traits::{AccountIdConversion, CheckedAdd, CheckedSub, Zero}, + ArithmeticError, DispatchResult, Perbill, RuntimeDebug, Saturating, +}; +use sp_staking::{EraIndex, Stake, StakerStatus, StakingInterface, StakingUnsafe}; +use sp_std::{convert::TryInto, prelude::*}; + +pub type BalanceOf = + <::Currency as FunInspect<::AccountId>>::Balance; + +use frame_system::{ensure_signed, pallet_prelude::*, RawOrigin}; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + #[pallet::pallet] + pub struct Pallet(PhantomData); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// Injected identifier for the pallet. + #[pallet::constant] + type PalletId: Get; + + /// Currency type. + type Currency: FunHoldMutate + + FunMutate + + FunHoldBalanced; + + /// Handler for the unbalanced reduction when slashing a delegator. + type OnSlash: OnUnbalanced>; + + /// Overarching hold reason. + type RuntimeHoldReason: From; + + /// Core staking implementation. + type CoreStaking: StakingUnsafe, AccountId = Self::AccountId>; + } + + #[pallet::error] + pub enum Error { + /// The account cannot perform this operation. + NotAllowed, + /// An existing staker cannot perform this action. + AlreadyStaking, + /// Reward Destination cannot be `delegatee` account. + InvalidRewardDestination, + /// Delegation conditions are not met. + /// + /// Possible issues are + /// 1) Cannot delegate to self, + /// 2) Cannot delegate to multiple delegates, + InvalidDelegation, + /// The account does not have enough funds to perform the operation. + NotEnoughFunds, + /// Not an existing delegatee account. + NotDelegatee, + /// Not a Delegator account. + NotDelegator, + /// Some corruption in internal state. + BadState, + /// Unapplied pending slash restricts operation on `delegatee`. + UnappliedSlash, + /// Failed to withdraw amount from Core Staking. + WithdrawFailed, + /// Operation not supported by this pallet. + NotSupported, + } + + /// A reason for placing a hold on funds. + #[pallet::composite_enum] + pub enum HoldReason { + /// Funds held for stake delegation to another account. + #[codec(index = 0)] + Delegating, + } + + #[pallet::event] + #[pallet::generate_deposit(pub (super) fn deposit_event)] + pub enum Event { + /// Funds delegated by a delegator. + Delegated { delegatee: T::AccountId, delegator: T::AccountId, amount: BalanceOf }, + /// Funds released to a delegator. + Released { delegatee: T::AccountId, delegator: T::AccountId, amount: BalanceOf }, + /// Funds slashed from a delegator. + Slashed { delegatee: T::AccountId, delegator: T::AccountId, amount: BalanceOf }, + } + + /// Map of Delegators to their `Delegation`. + /// + /// Implementation note: We are not using a double map with `delegator` and `delegatee` account + /// as keys since we want to restrict delegators to delegate only to one account at a time. + #[pallet::storage] + pub(crate) type Delegators = + CountedStorageMap<_, Twox64Concat, T::AccountId, Delegation, OptionQuery>; + + /// Map of `Delegatee` to their `DelegateeLedger`. + #[pallet::storage] + pub(crate) type Delegatees = + CountedStorageMap<_, Twox64Concat, T::AccountId, DelegateeLedger, OptionQuery>; + + #[pallet::call] + impl Pallet { + /// Register an account to be a `Delegatee`. + /// + /// `Delegatee` accounts accepts delegations from other `delegator`s and stake funds on + /// their behalf. + #[pallet::call_index(0)] + #[pallet::weight(Weight::default())] + pub fn register_as_delegatee( + origin: OriginFor, + reward_account: T::AccountId, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // Existing `delegatee` cannot register again. + ensure!(!Self::is_delegatee(&who), Error::::NotAllowed); + + // A delegator cannot become a `delegatee`. + ensure!(!Self::is_delegator(&who), Error::::NotAllowed); + + // They cannot be already a direct staker in the staking pallet. + ensure!(Self::not_direct_staker(&who), Error::::AlreadyStaking); + + // Reward account cannot be same as `delegatee` account. + ensure!(reward_account != who, Error::::InvalidRewardDestination); + + Self::do_register_delegatee(&who, &reward_account); + Ok(()) + } + + /// Migrate from a `Nominator` account to `Delegatee` account. + /// + /// The origin needs to + /// - be a `Nominator` with `CoreStaking`, + /// - not already a `Delegatee`, + /// - have enough funds to transfer existential deposit to a delegator account created for + /// the migration. + /// + /// This operation will create a new delegator account for the origin called + /// `proxy_delegator` and transfer the staked amount to it. The `proxy_delegator` delegates + /// the funds to the origin making origin a `Delegatee` account. The actual `delegator` + /// accounts of the origin can later migrate their funds using [Call::migrate_delegation] to + /// claim back their share of delegated funds from `proxy_delegator` to self. + #[pallet::call_index(1)] + #[pallet::weight(Weight::default())] + pub fn migrate_to_delegatee( + origin: OriginFor, + reward_account: T::AccountId, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + // ensure who is not already a delegatee. + ensure!(!Self::is_delegatee(&who), Error::::NotAllowed); + + // and they should already be a nominator in `CoreStaking`. + ensure!(Self::is_direct_nominator(&who), Error::::NotAllowed); + + // Reward account cannot be same as `delegatee` account. + ensure!(reward_account != who, Error::::InvalidRewardDestination); + + Self::do_migrate_to_delegatee(&who, &reward_account) + } + + /// Release delegated amount to delegator. + /// + /// This can be called by existing `delegatee` accounts. + /// + /// Tries to withdraw unbonded fund from `CoreStaking` if needed and release amount to + /// `delegator`. + #[pallet::call_index(2)] + #[pallet::weight(Weight::default())] + pub fn release( + origin: OriginFor, + delegator: T::AccountId, + amount: BalanceOf, + num_slashing_spans: u32, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + Self::do_release(&who, &delegator, amount, num_slashing_spans) + } + + /// Migrate delegated fund. + /// + /// This can be called by migrating `delegatee` accounts. + /// + /// This moves delegator funds from `pxoxy_delegator` account to `delegator` account. + #[pallet::call_index(3)] + #[pallet::weight(Weight::default())] + pub fn migrate_delegation( + origin: OriginFor, + delegator: T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + let delegatee = ensure_signed(origin)?; + + // Ensure they have minimum delegation. + ensure!(amount >= T::Currency::minimum_balance(), Error::::NotEnoughFunds); + + // Ensure delegator is sane. + ensure!(!Self::is_delegatee(&delegator), Error::::NotAllowed); + ensure!(!Self::is_delegator(&delegator), Error::::NotAllowed); + ensure!(Self::not_direct_staker(&delegator), Error::::AlreadyStaking); + + // ensure delegatee is sane. + ensure!(Self::is_delegatee(&delegatee), Error::::NotDelegatee); + + // and has enough delegated balance to migrate. + let proxy_delegator = Self::sub_account(AccountType::ProxyDelegator, delegatee); + let balance_remaining = Self::held_balance_of(&proxy_delegator); + ensure!(balance_remaining >= amount, Error::::NotEnoughFunds); + + Self::do_migrate_delegation(&proxy_delegator, &delegator, amount) + } + + /// Delegate funds to a `Delegatee` account and bonds it to [Config::CoreStaking]. + /// + /// If delegation already exists, it increases the delegation by `amount`. + #[pallet::call_index(4)] + #[pallet::weight(Weight::default())] + pub fn delegate_funds( + origin: OriginFor, + delegatee: T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // ensure amount is over minimum to delegate + ensure!(amount > T::Currency::minimum_balance(), Error::::NotEnoughFunds); + + // ensure delegator is sane. + ensure!(Delegation::::can_delegate(&who, &delegatee), Error::::InvalidDelegation); + ensure!(Self::not_direct_staker(&who), Error::::AlreadyStaking); + + // ensure delegatee is sane. + ensure!(Self::is_delegatee(&delegatee), Error::::NotDelegatee); + + let delegator_balance = + T::Currency::reducible_balance(&who, Preservation::Preserve, Fortitude::Polite); + ensure!(delegator_balance >= amount, Error::::NotEnoughFunds); + + // add to delegation + Self::do_delegate(&who, &delegatee, amount)?; + // bond the amount to `CoreStaking`. + Self::do_bond(&delegatee, amount) + } + + /// Apply slash to a delegator account. + /// + /// `Delegatee` accounts with pending slash in their ledger can call this to apply slash to + /// one of its `delegator` account. Each slash to a delegator account needs to be posted + /// separately until all pending slash is cleared. + #[pallet::call_index(5)] + #[pallet::weight(Weight::default())] + pub fn apply_slash( + origin: OriginFor, + delegator: T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + Self::do_slash(who, delegator, amount, None) + } + } + + #[pallet::hooks] + impl Hooks> for Pallet { + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), TryRuntimeError> { + Self::do_try_state() + } + + fn integrity_test() {} + } +} + +impl Pallet { + /// Derive a (keyless) pot account from the given delegatee account and account type. + pub(crate) fn sub_account( + account_type: AccountType, + delegatee_account: T::AccountId, + ) -> T::AccountId { + T::PalletId::get().into_sub_account_truncating((account_type, delegatee_account.clone())) + } + + /// Balance of a delegator that is delegated. + pub(crate) fn held_balance_of(who: &T::AccountId) -> BalanceOf { + T::Currency::balance_on_hold(&HoldReason::Delegating.into(), who) + } + + /// Returns true if who is registered as a `Delegatee`. + fn is_delegatee(who: &T::AccountId) -> bool { + >::contains_key(who) + } + + /// Returns true if who is delegating to a `Delegatee` account. + fn is_delegator(who: &T::AccountId) -> bool { + >::contains_key(who) + } + + /// Returns true if who is not already staking on [`Config::CoreStaking`]. + fn not_direct_staker(who: &T::AccountId) -> bool { + T::CoreStaking::status(&who).is_err() + } + + /// Returns true if who is a [`StakerStatus::Nominator`] on [`Config::CoreStaking`]. + fn is_direct_nominator(who: &T::AccountId) -> bool { + T::CoreStaking::status(who) + .map(|status| matches!(status, StakerStatus::Nominator(_))) + .unwrap_or(false) + } + + fn do_register_delegatee(who: &T::AccountId, reward_account: &T::AccountId) { + DelegateeLedger::::new(reward_account).save(who); + + // Delegatee is a virtual account. Make this account exist. + // TODO: Someday if we expose these calls in a runtime, we should take a deposit for + // being a delegator. + frame_system::Pallet::::inc_providers(who); + } + + fn do_migrate_to_delegatee( + who: &T::AccountId, + reward_account: &T::AccountId, + ) -> DispatchResult { + // We create a proxy delegator that will keep all the delegation funds until funds are + // transferred to actual delegator. + let proxy_delegator = Self::sub_account(AccountType::ProxyDelegator, who.clone()); + + // Transfer minimum balance to proxy delegator. + T::Currency::transfer( + who, + &proxy_delegator, + T::Currency::minimum_balance(), + Preservation::Protect, + ) + .map_err(|_| Error::::NotEnoughFunds)?; + + // Get current stake + let stake = T::CoreStaking::stake(who)?; + + // release funds from core staking. + T::CoreStaking::force_release(who); + + // transferring just released staked amount. This should never fail but if it does, it + // indicates bad state and we abort. + T::Currency::transfer(who, &proxy_delegator, stake.total, Preservation::Protect) + .map_err(|_| Error::::BadState)?; + + Self::do_register_delegatee(who, reward_account); + T::CoreStaking::update_payee(who, reward_account)?; + + Self::do_delegate(&proxy_delegator, who, stake.total) + } + + fn do_bond(delegatee_acc: &T::AccountId, amount: BalanceOf) -> DispatchResult { + let delegatee = Delegatee::::from(delegatee_acc)?; + + let available_to_bond = delegatee.available_to_bond(); + defensive_assert!(amount == available_to_bond, "not expected value to bond"); + + if delegatee.is_bonded() { + T::CoreStaking::bond_extra(&delegatee.key, amount) + } else { + T::CoreStaking::virtual_bond(&delegatee.key, amount, &delegatee.reward_account()) + } + } + + fn do_delegate( + delegator: &T::AccountId, + delegatee: &T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + let mut ledger = DelegateeLedger::::get(delegatee).ok_or(Error::::NotDelegatee)?; + + let new_delegation_amount = + if let Some(existing_delegation) = Delegation::::get(delegator) { + ensure!(&existing_delegation.delegatee == delegatee, Error::::InvalidDelegation); + existing_delegation + .amount + .checked_add(&amount) + .ok_or(ArithmeticError::Overflow)? + } else { + amount + }; + + Delegation::::from(delegatee, new_delegation_amount).save_or_kill(delegator); + ledger.total_delegated = + ledger.total_delegated.checked_add(&amount).ok_or(ArithmeticError::Overflow)?; + ledger.save(delegatee); + + T::Currency::hold(&HoldReason::Delegating.into(), delegator, amount)?; + + Self::deposit_event(Event::::Delegated { + delegatee: delegatee.clone(), + delegator: delegator.clone(), + amount, + }); + + Ok(()) + } + + fn do_release( + who: &T::AccountId, + delegator: &T::AccountId, + amount: BalanceOf, + num_slashing_spans: u32, + ) -> DispatchResult { + let mut delegatee = Delegatee::::from(who)?; + let mut delegation = Delegation::::get(delegator).ok_or(Error::::NotDelegator)?; + + // make sure delegation to be released is sound. + ensure!(&delegation.delegatee == who, Error::::NotDelegatee); + ensure!(delegation.amount >= amount, Error::::NotEnoughFunds); + + // if we do not already have enough funds to be claimed, try withdraw some more. + if delegatee.ledger.unclaimed_withdrawals < amount { + // get the updated delegatee + delegatee = Self::withdraw_unbonded(who, num_slashing_spans)?; + } + + // if we still do not have enough funds to release, abort. + ensure!(delegatee.ledger.unclaimed_withdrawals >= amount, Error::::NotEnoughFunds); + + // claim withdraw from delegatee. + delegatee.remove_unclaimed_withdraw(amount)?.save_or_kill()?; + + // book keep delegation + delegation.amount = delegation + .amount + .checked_sub(&amount) + .defensive_ok_or(ArithmeticError::Overflow)?; + + // remove delegator if nothing delegated anymore + delegation.save_or_kill(delegator); + + let released = T::Currency::release( + &HoldReason::Delegating.into(), + &delegator, + amount, + Precision::BestEffort, + )?; + + defensive_assert!(released == amount, "hold should have been released fully"); + + Self::deposit_event(Event::::Released { + delegatee: who.clone(), + delegator: delegator.clone(), + amount, + }); + + Ok(()) + } + + fn withdraw_unbonded( + delegatee_acc: &T::AccountId, + num_slashing_spans: u32, + ) -> Result, DispatchError> { + let delegatee = Delegatee::::from(delegatee_acc)?; + let pre_total = T::CoreStaking::stake(delegatee_acc).defensive()?.total; + + let stash_killed: bool = + T::CoreStaking::withdraw_unbonded(delegatee_acc.clone(), num_slashing_spans) + .map_err(|_| Error::::WithdrawFailed)?; + + let maybe_post_total = T::CoreStaking::stake(delegatee_acc); + // One of them should be true + defensive_assert!( + !(stash_killed && maybe_post_total.is_ok()), + "something horrible happened while withdrawing" + ); + + let post_total = maybe_post_total.map_or(Zero::zero(), |s| s.total); + + let new_withdrawn = + pre_total.checked_sub(&post_total).defensive_ok_or(Error::::BadState)?; + + let delegatee = delegatee.add_unclaimed_withdraw(new_withdrawn)?; + + delegatee.clone().save(); + + Ok(delegatee) + } + + /// Migrates delegation of `amount` from `source` account to `destination` account. + fn do_migrate_delegation( + source_delegator: &T::AccountId, + destination_delegator: &T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + let source_delegation = + Delegators::::get(&source_delegator).defensive_ok_or(Error::::BadState)?; + + // some checks that must have already been checked before. + ensure!(source_delegation.amount >= amount, Error::::NotEnoughFunds); + debug_assert!( + !Self::is_delegator(destination_delegator) && + !Self::is_delegatee(destination_delegator) + ); + + // update delegations + Delegation::::from(&source_delegation.delegatee, amount) + .save_or_kill(destination_delegator); + + source_delegation + .decrease_delegation(amount) + .defensive_ok_or(Error::::BadState)? + .save_or_kill(source_delegator); + + // release funds from source + let released = T::Currency::release( + &HoldReason::Delegating.into(), + &source_delegator, + amount, + Precision::BestEffort, + )?; + + defensive_assert!(released == amount, "hold should have been released fully"); + + // transfer the released amount to `destination_delegator`. + // Note: The source should have been funded ED in the beginning so it should not be dusted. + T::Currency::transfer( + &source_delegator, + destination_delegator, + amount, + Preservation::Preserve, + ) + .map_err(|_| Error::::BadState)?; + + // hold the funds again in the new delegator account. + T::Currency::hold(&HoldReason::Delegating.into(), &destination_delegator, amount)?; + + Ok(()) + } + + pub fn do_slash( + delegatee_acc: T::AccountId, + delegator: T::AccountId, + amount: BalanceOf, + maybe_reporter: Option, + ) -> DispatchResult { + let delegatee = Delegatee::::from(&delegatee_acc)?; + let delegation = >::get(&delegator).ok_or(Error::::NotDelegator)?; + + ensure!(delegation.delegatee == delegatee_acc, Error::::NotDelegatee); + ensure!(delegation.amount >= amount, Error::::NotEnoughFunds); + + let (mut credit, missing) = + T::Currency::slash(&HoldReason::Delegating.into(), &delegator, amount); + + defensive_assert!(missing.is_zero(), "slash should have been fully applied"); + + let actual_slash = credit.peek(); + + // remove the applied slashed amount from delegatee. + delegatee.remove_slash(actual_slash).save(); + + delegation + .decrease_delegation(actual_slash) + .ok_or(ArithmeticError::Overflow)? + .save_or_kill(&delegator); + + if let Some(reporter) = maybe_reporter { + let reward_payout: BalanceOf = + T::CoreStaking::slash_reward_fraction() * actual_slash; + let (reporter_reward, rest) = credit.split(reward_payout); + credit = rest; + + // fixme(ank4n): handle error + let _ = T::Currency::resolve(&reporter, reporter_reward); + } + + T::OnSlash::on_unbalanced(credit); + + Self::deposit_event(Event::::Slashed { delegatee: delegatee_acc, delegator, amount }); + + Ok(()) + } + + /// Total balance that is available for stake. Includes already staked amount. + #[cfg(test)] + pub(crate) fn stakeable_balance(who: &T::AccountId) -> BalanceOf { + Delegatee::::from(who) + .map(|delegatee| delegatee.ledger.stakeable_balance()) + .unwrap_or_default() + } +} + +#[cfg(any(test, feature = "try-runtime"))] +use sp_std::collections::btree_map::BTreeMap; + +#[cfg(any(test, feature = "try-runtime"))] +impl Pallet { + pub(crate) fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + // build map to avoid reading storage multiple times. + let delegation_map = Delegators::::iter().collect::>(); + let ledger_map = Delegatees::::iter().collect::>(); + + Self::check_delegates(ledger_map.clone())?; + Self::check_delegators(delegation_map, ledger_map)?; + + Ok(()) + } + + fn check_delegates( + ledgers: BTreeMap>, + ) -> Result<(), sp_runtime::TryRuntimeError> { + for (delegatee, ledger) in ledgers { + ensure!( + matches!( + T::CoreStaking::status(&delegatee).expect("delegatee should be bonded"), + StakerStatus::Nominator(_) | StakerStatus::Idle + ), + "delegatee should be bonded and not validator" + ); + + ensure!( + ledger.stakeable_balance() >= + T::CoreStaking::total_stake(&delegatee) + .expect("delegatee should exist as a nominator"), + "Cannot stake more than balance" + ); + } + + Ok(()) + } + + fn check_delegators( + delegations: BTreeMap>, + ledger: BTreeMap>, + ) -> Result<(), sp_runtime::TryRuntimeError> { + let mut delegation_aggregation = BTreeMap::>::new(); + for (delegator, delegation) in delegations.iter() { + ensure!( + T::CoreStaking::status(delegator).is_err(), + "delegator should not be directly staked" + ); + ensure!(!Self::is_delegatee(delegator), "delegator cannot be delegatee"); + + delegation_aggregation + .entry(delegation.delegatee.clone()) + .and_modify(|e| *e += delegation.amount) + .or_insert(delegation.amount); + } + + for (delegatee, total_delegated) in delegation_aggregation { + ensure!(!Self::is_delegator(&delegatee), "delegatee cannot be delegator"); + + let ledger = ledger.get(&delegatee).expect("ledger should exist"); + ensure!( + ledger.total_delegated == total_delegated, + "ledger total delegated should match delegations" + ); + } + + Ok(()) + } +} diff --git a/substrate/frame/delegated-staking/src/mock.rs b/substrate/frame/delegated-staking/src/mock.rs new file mode 100644 index 000000000000..ae1b84acdecb --- /dev/null +++ b/substrate/frame/delegated-staking/src/mock.rs @@ -0,0 +1,359 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::{self as delegated_staking, types::Delegatee, HoldReason}; +use frame_support::{ + assert_ok, derive_impl, + pallet_prelude::*, + parameter_types, + traits::{fungible::InspectHold, ConstU64, Currency}, + PalletId, +}; + +use sp_runtime::{traits::IdentityLookup, BuildStorage, Perbill}; + +use frame_election_provider_support::{ + bounds::{ElectionBounds, ElectionBoundsBuilder}, + onchain, SequentialPhragmen, +}; +use frame_support::dispatch::RawOrigin; +use pallet_staking::CurrentEra; +use sp_core::U256; +use sp_runtime::traits::Convert; +use sp_staking::{Stake, StakingInterface}; + +pub type T = Runtime; +type Block = frame_system::mocking::MockBlock; +pub type AccountId = u128; + +pub const GENESIS_VALIDATOR: AccountId = 1; +pub const GENESIS_NOMINATOR_ONE: AccountId = 101; +pub const GENESIS_NOMINATOR_TWO: AccountId = 102; + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig as frame_system::DefaultConfig)] +impl frame_system::Config for Runtime { + type Block = Block; + type AccountData = pallet_balances::AccountData; + type AccountId = AccountId; + type Lookup = IdentityLookup; +} + +impl pallet_timestamp::Config for Runtime { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<5>; + type WeightInfo = (); +} + +pub type Balance = u128; + +parameter_types! { + pub static ExistentialDeposit: Balance = 1; +} +impl pallet_balances::Config for Runtime { + type MaxLocks = ConstU32<128>; + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type FreezeIdentifier = RuntimeFreezeReason; + type MaxFreezes = ConstU32<1>; + type RuntimeHoldReason = RuntimeHoldReason; + type RuntimeFreezeReason = RuntimeFreezeReason; +} + +pallet_staking_reward_curve::build! { + const I_NPOS: sp_runtime::curve::PiecewiseLinear<'static> = curve!( + min_inflation: 0_025_000, + max_inflation: 0_100_000, + ideal_stake: 0_500_000, + falloff: 0_050_000, + max_piece_count: 40, + test_precision: 0_005_000, + ); +} + +parameter_types! { + pub const RewardCurve: &'static sp_runtime::curve::PiecewiseLinear<'static> = &I_NPOS; + pub static BondingDuration: u32 = 3; + pub static ElectionsBoundsOnChain: ElectionBounds = ElectionBoundsBuilder::default().build(); +} +pub struct OnChainSeqPhragmen; +impl onchain::Config for OnChainSeqPhragmen { + type System = Runtime; + type Solver = SequentialPhragmen; + type DataProvider = Staking; + type WeightInfo = (); + type MaxWinners = ConstU32<100>; + type Bounds = ElectionsBoundsOnChain; +} + +impl pallet_staking::Config for Runtime { + type Currency = Balances; + type CurrencyBalance = Balance; + type UnixTime = pallet_timestamp::Pallet; + type CurrencyToVote = (); + type RewardRemainder = (); + type RuntimeEvent = RuntimeEvent; + type Slash = (); + type Reward = (); + type SessionsPerEra = (); + type SlashDeferDuration = (); + type AdminOrigin = frame_system::EnsureRoot; + type BondingDuration = BondingDuration; + type SessionInterface = (); + type EraPayout = pallet_staking::ConvertCurve; + type NextNewSession = (); + type HistoryDepth = ConstU32<84>; + type MaxExposurePageSize = ConstU32<64>; + type OffendingValidatorsThreshold = (); + type ElectionProvider = onchain::OnChainExecution; + type GenesisElectionProvider = Self::ElectionProvider; + type VoterList = pallet_staking::UseNominatorsAndValidatorsMap; + type TargetList = pallet_staking::UseValidatorsMap; + type NominationsQuota = pallet_staking::FixedNominationsQuota<16>; + type MaxUnlockingChunks = ConstU32<32>; + type MaxControllersInDeprecationBatch = ConstU32<100>; + type EventListeners = (Pools, DelegatedStaking); + type BenchmarkingConfig = pallet_staking::TestBenchmarkingConfig; + type WeightInfo = (); +} + +parameter_types! { + pub const DelegatedStakingPalletId: PalletId = PalletId(*b"py/dlstk"); +} +impl delegated_staking::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type PalletId = DelegatedStakingPalletId; + type Currency = Balances; + type OnSlash = (); + type RuntimeHoldReason = RuntimeHoldReason; + type CoreStaking = Staking; +} + +pub struct BalanceToU256; +impl Convert for BalanceToU256 { + fn convert(n: Balance) -> U256 { + n.into() + } +} +pub struct U256ToBalance; +impl Convert for U256ToBalance { + fn convert(n: U256) -> Balance { + n.try_into().unwrap() + } +} + +parameter_types! { + pub static MaxUnbonding: u32 = 8; + pub const PoolsPalletId: PalletId = PalletId(*b"py/nopls"); +} +impl pallet_nomination_pools::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); + type Currency = Balances; + type RuntimeFreezeReason = RuntimeFreezeReason; + type RewardCounter = sp_runtime::FixedU128; + type BalanceToU256 = BalanceToU256; + type U256ToBalance = U256ToBalance; + type PostUnbondingPoolsWindow = ConstU32<2>; + type PalletId = PoolsPalletId; + type MaxMetadataLen = ConstU32<256>; + type MaxUnbonding = MaxUnbonding; + type MaxPointsToBalance = frame_support::traits::ConstU8<10>; + type StakeAdapter = pallet_nomination_pools::adapter::DelegateStake; +} + +frame_support::construct_runtime!( + pub enum Runtime { + System: frame_system, + Timestamp: pallet_timestamp, + Balances: pallet_balances, + Staking: pallet_staking, + DelegatedStaking: delegated_staking, + Pools: pallet_nomination_pools, + } +); + +pub struct ExtBuilder {} + +impl Default for ExtBuilder { + fn default() -> Self { + Self {} + } +} + +impl ExtBuilder { + fn build(self) -> sp_io::TestExternalities { + sp_tracing::try_init_simple(); + let mut storage = + frame_system::GenesisConfig::::default().build_storage().unwrap(); + + let _ = pallet_balances::GenesisConfig:: { + balances: vec![ + (GENESIS_VALIDATOR, 10000), + (GENESIS_NOMINATOR_ONE, 1000), + (GENESIS_NOMINATOR_TWO, 2000), + ], + } + .assimilate_storage(&mut storage); + + let stakers = vec![ + ( + GENESIS_VALIDATOR, + GENESIS_VALIDATOR, + 1000, + sp_staking::StakerStatus::::Validator, + ), + ( + GENESIS_NOMINATOR_ONE, + GENESIS_NOMINATOR_ONE, + 100, + sp_staking::StakerStatus::::Nominator(vec![1]), + ), + ( + GENESIS_NOMINATOR_TWO, + GENESIS_NOMINATOR_TWO, + 200, + sp_staking::StakerStatus::::Nominator(vec![1]), + ), + ]; + + let _ = pallet_staking::GenesisConfig:: { + stakers: stakers.clone(), + // ideal validator count + validator_count: 2, + minimum_validator_count: 1, + invulnerables: vec![], + slash_reward_fraction: Perbill::from_percent(10), + min_nominator_bond: ExistentialDeposit::get(), + min_validator_bond: ExistentialDeposit::get(), + ..Default::default() + } + .assimilate_storage(&mut storage); + + let mut ext = sp_io::TestExternalities::from(storage); + + ext.execute_with(|| { + // for events to be deposited. + frame_system::Pallet::::set_block_number(1); + }); + + ext + } + pub fn build_and_execute(self, test: impl FnOnce() -> ()) { + sp_tracing::try_init_simple(); + let mut ext = self.build(); + ext.execute_with(test); + ext.execute_with(|| { + // make sure pool state is correct. + Pools::do_try_state(255).unwrap(); + DelegatedStaking::do_try_state().unwrap(); + }); + } +} + +/// fund and return who. +pub(crate) fn fund(who: &AccountId, amount: Balance) { + let _ = Balances::deposit_creating(who, amount); +} + +/// Sets up delegation for passed delegators, returns total delegated amount. +/// +/// `delegate_amount` is incremented by the amount `increment` starting with `base_delegate_amount` +/// from lower index to higher index of delegators. +pub(crate) fn setup_delegation_stake( + delegatee: AccountId, + reward_acc: AccountId, + delegators: Vec, + base_delegate_amount: Balance, + increment: Balance, +) -> Balance { + fund(&delegatee, 100); + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee).into(), + reward_acc + )); + let mut delegated_amount: Balance = 0; + for (index, delegator) in delegators.iter().enumerate() { + let amount_to_delegate = base_delegate_amount + increment * index as Balance; + delegated_amount += amount_to_delegate; + + fund(delegator, amount_to_delegate + ExistentialDeposit::get()); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator.clone()).into(), + delegatee, + amount_to_delegate + )); + } + + // sanity checks + assert_eq!(DelegatedStaking::stakeable_balance(&delegatee), delegated_amount); + assert_eq!(Delegatee::::from(&delegatee).unwrap().available_to_bond(), 0); + + delegated_amount +} + +pub(crate) fn start_era(era: sp_staking::EraIndex) { + CurrentEra::::set(Some(era)); +} + +pub(crate) fn eq_stake(who: AccountId, total: Balance, active: Balance) -> bool { + Staking::stake(&who).unwrap() == Stake { total, active } && + get_delegatee(&who).ledger.stakeable_balance() == total +} + +pub(crate) fn get_delegatee(delegatee: &AccountId) -> Delegatee { + Delegatee::::from(delegatee).expect("delegate should exist") +} + +pub(crate) fn held_balance(who: &AccountId) -> Balance { + Balances::balance_on_hold(&HoldReason::Delegating.into(), &who) +} + +parameter_types! { + static ObservedEventsPools: usize = 0; + static ObservedEventsDelegatedStaking: usize = 0; +} + +pub(crate) fn pool_events_since_last_call() -> Vec> { + let events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| if let RuntimeEvent::Pools(inner) = e { Some(inner) } else { None }) + .collect::>(); + let already_seen = ObservedEventsPools::get(); + ObservedEventsPools::set(events.len()); + events.into_iter().skip(already_seen).collect() +} + +pub(crate) fn events_since_last_call() -> Vec> { + let events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map( + |e| if let RuntimeEvent::DelegatedStaking(inner) = e { Some(inner) } else { None }, + ) + .collect::>(); + let already_seen = ObservedEventsDelegatedStaking::get(); + ObservedEventsDelegatedStaking::set(events.len()); + events.into_iter().skip(already_seen).collect() +} diff --git a/substrate/frame/delegated-staking/src/tests.rs b/substrate/frame/delegated-staking/src/tests.rs new file mode 100644 index 000000000000..34f8399f340d --- /dev/null +++ b/substrate/frame/delegated-staking/src/tests.rs @@ -0,0 +1,1040 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Tests for pallet-delegated-staking. + +use super::*; +use crate::mock::*; +use frame_support::{assert_noop, assert_ok, traits::fungible::InspectHold}; +use pallet_nomination_pools::{Error as PoolsError, Event as PoolsEvent}; +use pallet_staking::Error as StakingError; + +#[test] +fn create_a_delegatee_with_first_delegator() { + ExtBuilder::default().build_and_execute(|| { + let delegatee: AccountId = 200; + let reward_account: AccountId = 201; + let delegator: AccountId = 202; + + // set intention to accept delegation. + fund(&delegatee, 1000); + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee).into(), + reward_account + )); + + // delegate to this account + fund(&delegator, 1000); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator).into(), + delegatee, + 100 + )); + + // verify + assert!(DelegatedStaking::is_delegatee(&delegatee)); + assert_eq!(DelegatedStaking::stakeable_balance(&delegatee), 100); + assert_eq!(Balances::balance_on_hold(&HoldReason::Delegating.into(), &delegator), 100); + }); +} + +#[test] +fn cannot_become_delegatee() { + ExtBuilder::default().build_and_execute(|| { + // cannot set reward account same as delegatee account + assert_noop!( + DelegatedStaking::register_as_delegatee(RawOrigin::Signed(100).into(), 100), + Error::::InvalidRewardDestination + ); + + // an existing validator cannot become delegatee + assert_noop!( + DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(mock::GENESIS_VALIDATOR).into(), + 100 + ), + Error::::AlreadyStaking + ); + + // an existing nominator cannot become delegatee + assert_noop!( + DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(mock::GENESIS_NOMINATOR_ONE).into(), + 100 + ), + Error::::AlreadyStaking + ); + assert_noop!( + DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(mock::GENESIS_NOMINATOR_TWO).into(), + 100 + ), + Error::::AlreadyStaking + ); + }); +} + +#[test] +fn create_multiple_delegators() { + ExtBuilder::default().build_and_execute(|| { + let delegatee: AccountId = 200; + let reward_account: AccountId = 201; + + // stakeable balance is 0 for non delegatee + fund(&delegatee, 1000); + assert!(!DelegatedStaking::is_delegatee(&delegatee)); + assert_eq!(DelegatedStaking::stakeable_balance(&delegatee), 0); + + // set intention to accept delegation. + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee).into(), + reward_account + )); + + // create 100 delegators + for i in 202..302 { + fund(&i, 100 + ExistentialDeposit::get()); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(i).into(), + delegatee, + 100 + )); + // Balance of 100 held on delegator account for delegating to the delegatee. + assert_eq!(Balances::balance_on_hold(&HoldReason::Delegating.into(), &i), 100); + } + + // verify + assert!(DelegatedStaking::is_delegatee(&delegatee)); + assert_eq!(DelegatedStaking::stakeable_balance(&delegatee), 100 * 100); + }); +} + +#[test] +fn delegatee_restrictions() { + // Similar to creating a nomination pool + ExtBuilder::default().build_and_execute(|| { + let delegatee_one = 200; + let delegator_one = 210; + fund(&delegatee_one, 100); + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee_one).into(), + delegatee_one + 1 + )); + fund(&delegator_one, 200); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator_one).into(), + delegatee_one, + 100 + )); + + let delegatee_two = 300; + let delegator_two = 310; + fund(&delegatee_two, 100); + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee_two).into(), + delegatee_two + 1 + )); + fund(&delegator_two, 200); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator_two).into(), + delegatee_two, + 100 + )); + + // delegatee one tries to delegate to delegatee 2 + assert_noop!( + DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegatee_one).into(), + delegatee_two, + 10 + ), + Error::::InvalidDelegation + ); + + // delegatee one tries to delegate to a delegator + assert_noop!( + DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegatee_one).into(), + delegator_one, + 10 + ), + Error::::InvalidDelegation + ); + assert_noop!( + DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegatee_one).into(), + delegator_two, + 10 + ), + Error::::InvalidDelegation + ); + + // delegator one tries to delegate to delegatee 2 as well (it already delegates to delegatee + // 1) + assert_noop!( + DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator_one).into(), + delegatee_two, + 10 + ), + Error::::InvalidDelegation + ); + }); +} + +#[test] +fn apply_pending_slash() { + ExtBuilder::default().build_and_execute(|| { + ( + // fixme(ank4n): add tests for apply_pending_slash + ) + }); +} + +/// Integration tests with pallet-staking. +mod staking_integration { + use super::*; + use pallet_staking::RewardDestination; + use sp_staking::Stake; + + #[test] + fn bond() { + ExtBuilder::default().build_and_execute(|| { + let delegatee: AccountId = 99; + let reward_acc: AccountId = 100; + assert_eq!(Staking::status(&delegatee), Err(StakingError::::NotStash.into())); + + // set intention to become a delegatee + fund(&delegatee, 100); + assert_ok!(DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(delegatee).into(), + reward_acc + )); + assert_eq!(DelegatedStaking::stakeable_balance(&delegatee), 0); + + let mut delegated_balance: Balance = 0; + + // set some delegations + for delegator in 200..250 { + fund(&delegator, 200); + assert_ok!(DelegatedStaking::delegate_funds( + RawOrigin::Signed(delegator).into(), + delegatee, + 100 + )); + delegated_balance += 100; + assert_eq!( + Balances::balance_on_hold(&HoldReason::Delegating.into(), &delegator), + 100 + ); + + let delegatee_obj = get_delegatee(&delegatee); + assert_eq!(delegatee_obj.ledger.stakeable_balance(), delegated_balance); + assert_eq!(delegatee_obj.available_to_bond(), 0); + assert_eq!(delegatee_obj.bonded_stake(), delegated_balance); + } + + assert_eq!( + Staking::stake(&delegatee).unwrap(), + Stake { total: 50 * 100, active: 50 * 100 } + ) + }); + } + + #[test] + fn withdraw_test() { + ExtBuilder::default().build_and_execute(|| { + // initial era + start_era(1); + let delegatee: AccountId = 200; + let reward_acc: AccountId = 201; + let delegators: Vec = (301..=350).collect(); + let total_staked = + setup_delegation_stake(delegatee, reward_acc, delegators.clone(), 10, 10); + + // lets go to a new era + start_era(2); + + assert!(eq_stake(delegatee, total_staked, total_staked)); + // Withdrawing without unbonding would fail. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 301, 50, 0), + Error::::NotEnoughFunds + ); + // assert_noop!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 200, 50, + // 0), Error::::NotAllowed); active and total stake remains same + assert!(eq_stake(delegatee, total_staked, total_staked)); + + // 305 wants to unbond 50 in era 2, withdrawable in era 5. + assert_ok!(DelegatedStaking::unbond(&delegatee, 50)); + // 310 wants to unbond 100 in era 3, withdrawable in era 6. + start_era(3); + assert_ok!(DelegatedStaking::unbond(&delegatee, 100)); + // 320 wants to unbond 200 in era 4, withdrawable in era 7. + start_era(4); + assert_ok!(DelegatedStaking::unbond(&delegatee, 200)); + + // active stake is now reduced.. + let expected_active = total_staked - (50 + 100 + 200); + assert!(eq_stake(delegatee, total_staked, expected_active)); + + // nothing to withdraw at era 4 + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 305, 50, 0), + Error::::NotEnoughFunds + ); + + assert!(eq_stake(delegatee, total_staked, expected_active)); + assert_eq!(get_delegatee(&delegatee).available_to_bond(), 0); + // full amount is still delegated + assert_eq!(get_delegatee(&delegatee).ledger.effective_balance(), total_staked); + + start_era(5); + // at era 5, 50 tokens are withdrawable, cannot withdraw more. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 305, 51, 0), + Error::::NotEnoughFunds + ); + // less is possible + assert_ok!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 305, 30, 0)); + assert_ok!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 305, 20, 0)); + + // Lets go to future era where everything is unbonded. Withdrawable amount: 100 + 200 + start_era(7); + // 305 has no more amount delegated so it cannot withdraw. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 305, 5, 0), + Error::::NotDelegator + ); + // 309 is an active delegator but has total delegation of 90, so it cannot withdraw more + // than that. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 309, 91, 0), + Error::::NotEnoughFunds + ); + // 310 cannot withdraw more than delegated funds. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 310, 101, 0), + Error::::NotEnoughFunds + ); + // but can withdraw all its delegation amount. + assert_ok!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 310, 100, 0)); + // 320 can withdraw all its delegation amount. + assert_ok!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 320, 200, 0)); + + // cannot withdraw anything more.. + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 301, 1, 0), + Error::::NotEnoughFunds + ); + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 350, 1, 0), + Error::::NotEnoughFunds + ); + }); + } + + #[test] + fn withdraw_happens_with_unbonded_balance_first() { + ExtBuilder::default().build_and_execute(|| { + let delegatee = 200; + setup_delegation_stake(delegatee, 201, (300..350).collect(), 100, 0); + + // verify withdraw not possible yet + assert_noop!( + DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 300, 100, 0), + Error::::NotEnoughFunds + ); + + // add new delegation that is not staked + + // FIXME(ank4n): add scenario where staked funds are withdrawn from ledger but not + // withdrawn and test its claimed from there first. + + // fund(&300, 1000); + // assert_ok!(DelegatedStaking::delegate_funds(RawOrigin::Signed(300.into()), delegate, + // 100)); + // + // // verify unbonded balance + // assert_eq!(get_delegatee(&delegatee).available_to_bond(), 100); + // + // // withdraw works now without unbonding + // assert_ok!(DelegatedStaking::release(RawOrigin::Signed(delegatee).into(), 300, 100, + // 0)); assert_eq!(get_delegatee(&delegatee).available_to_bond(), 0); + }); + } + + #[test] + fn reward_destination_restrictions() { + ExtBuilder::default().build_and_execute(|| { + // give some funds to 200 + fund(&200, 1000); + let balance_200 = Balances::free_balance(200); + + // `delegatee` account cannot be reward destination + assert_noop!( + DelegatedStaking::register_as_delegatee(RawOrigin::Signed(200).into(), 200), + Error::::InvalidRewardDestination + ); + + // different reward account works + assert_ok!(DelegatedStaking::register_as_delegatee(RawOrigin::Signed(200).into(), 201)); + // add some delegations to it + fund(&300, 1000); + assert_ok!(DelegatedStaking::delegate_funds(RawOrigin::Signed(300).into(), 200, 100)); + + // if delegate calls Staking pallet directly with a different reward destination, it + // fails. + assert_noop!( + Staking::set_payee(RuntimeOrigin::signed(200), RewardDestination::Stash), + StakingError::::RewardDestinationRestricted + ); + + // passing correct reward destination works + assert_ok!(Staking::set_payee( + RuntimeOrigin::signed(200), + RewardDestination::Account(201) + )); + + // amount is staked correctly + assert!(eq_stake(200, 100, 100)); + assert_eq!(get_delegatee(&200).available_to_bond(), 0); + assert_eq!(get_delegatee(&200).ledger.effective_balance(), 100); + + // free balance of delegate is untouched + assert_eq!(Balances::free_balance(200), balance_200); + }); + } + + #[test] + fn delegatee_restrictions() { + ExtBuilder::default().build_and_execute(|| { + setup_delegation_stake(200, 201, (202..203).collect(), 100, 0); + + // Registering again is noop + assert_noop!( + DelegatedStaking::register_as_delegatee(RawOrigin::Signed(200).into(), 201), + Error::::NotAllowed + ); + // a delegator cannot become delegate + assert_noop!( + DelegatedStaking::register_as_delegatee(RawOrigin::Signed(202).into(), 203), + Error::::NotAllowed + ); + // existing staker cannot become a delegate + assert_noop!( + DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(GENESIS_NOMINATOR_ONE).into(), + 201 + ), + Error::::AlreadyStaking + ); + assert_noop!( + DelegatedStaking::register_as_delegatee( + RawOrigin::Signed(GENESIS_VALIDATOR).into(), + 201 + ), + Error::::AlreadyStaking + ); + }); + } + + #[test] + fn slash_works() { + ExtBuilder::default().build_and_execute(|| { + setup_delegation_stake(200, 201, (210..250).collect(), 100, 0); + start_era(1); + // fixme(ank4n): add tests for slashing + }); + } + + #[test] + fn migration_works() { + ExtBuilder::default().build_and_execute(|| { + // add a nominator + fund(&200, 5000); + let staked_amount = 4000; + assert_ok!(Staking::bond( + RuntimeOrigin::signed(200), + staked_amount, + RewardDestination::Account(201) + )); + assert_ok!(Staking::nominate(RuntimeOrigin::signed(200), vec![GENESIS_VALIDATOR],)); + let init_stake = Staking::stake(&200).unwrap(); + + // scenario: 200 is a pool account, and the stake comes from its 4 delegators (300..304) + // in equal parts. lets try to migrate this nominator into delegate based stake. + + // all balance currently is in 200 + assert_eq!(Balances::free_balance(200), 5000); + + // to migrate, nominator needs to set an account as a proxy delegator where staked funds + // will be moved and delegated back to this old nominator account. This should be funded + // with at least ED. + let proxy_delegator = DelegatedStaking::sub_account(AccountType::ProxyDelegator, 200); + + assert_ok!(DelegatedStaking::migrate_to_delegatee(RawOrigin::Signed(200).into(), 201)); + + // verify all went well + let mut expected_proxy_delegated_amount = staked_amount; + assert_eq!( + Balances::balance_on_hold(&HoldReason::Delegating.into(), &proxy_delegator), + expected_proxy_delegated_amount + ); + // ED + stake amount is transferred from delegate to proxy delegator account. + assert_eq!( + Balances::free_balance(200), + 5000 - staked_amount - ExistentialDeposit::get() + ); + assert_eq!(DelegatedStaking::stake(&200).unwrap(), init_stake); + assert_eq!(get_delegatee(&200).ledger.effective_balance(), 4000); + assert_eq!(get_delegatee(&200).available_to_bond(), 0); + + // now lets migrate the delegators + let delegator_share = staked_amount / 4; + for delegator in 300..304 { + assert_eq!(Balances::free_balance(delegator), 0); + // fund them with ED + fund(&delegator, ExistentialDeposit::get()); + // migrate 1/4th amount into each delegator + assert_ok!(DelegatedStaking::migrate_delegation( + RawOrigin::Signed(200).into(), + delegator, + delegator_share + )); + assert_eq!( + Balances::balance_on_hold(&HoldReason::Delegating.into(), &delegator), + delegator_share + ); + expected_proxy_delegated_amount -= delegator_share; + assert_eq!( + Balances::balance_on_hold(&HoldReason::Delegating.into(), &proxy_delegator), + expected_proxy_delegated_amount + ); + + // delegate stake is unchanged. + assert_eq!(DelegatedStaking::stake(&200).unwrap(), init_stake); + assert_eq!(get_delegatee(&200).ledger.effective_balance(), 4000); + assert_eq!(get_delegatee(&200).available_to_bond(), 0); + } + + // cannot use migrate delegator anymore + assert_noop!( + DelegatedStaking::migrate_delegation(RawOrigin::Signed(200).into(), 305, 1), + Error::::NotEnoughFunds + ); + }); + } +} + +// FIMXE(ank4n): Move these integration test to nomination pools. +mod pool_integration { + use super::*; + use pallet_nomination_pools::{BondExtra, BondedPools, PoolState}; + + #[test] + fn create_pool_test() { + ExtBuilder::default().build_and_execute(|| { + let creator: AccountId = 100; + fund(&creator, 500); + let delegate_amount = 200; + + // nothing held initially + assert_eq!(held_balance(&creator), 0); + + // create pool + assert_ok!(Pools::create( + RawOrigin::Signed(creator).into(), + delegate_amount, + creator, + creator, + creator + )); + + // correct amount is locked in depositor's account. + assert_eq!(held_balance(&creator), delegate_amount); + + let pool_account = Pools::create_bonded_account(1); + let delegatee = get_delegatee(&pool_account); + + // verify state + assert_eq!(delegatee.ledger.effective_balance(), delegate_amount); + assert_eq!(delegatee.available_to_bond(), 0); + assert_eq!(delegatee.total_unbonded(), 0); + }); + } + + #[test] + fn join_pool() { + ExtBuilder::default().build_and_execute(|| { + // create a pool + let pool_id = create_pool(100, 200); + // keep track of staked amount. + let mut staked_amount: Balance = 200; + + // fund delegator + let delegator: AccountId = 300; + fund(&delegator, 500); + // nothing held initially + assert_eq!(held_balance(&delegator), 0); + + // delegator joins pool + assert_ok!(Pools::join(RawOrigin::Signed(delegator).into(), 100, pool_id)); + staked_amount += 100; + + // correct amount is locked in depositor's account. + assert_eq!(held_balance(&delegator), 100); + + // delegator is not actively exposed to core staking. + assert_eq!(Staking::status(&delegator), Err(StakingError::::NotStash.into())); + + let pool_delegatee = get_delegatee(&Pools::create_bonded_account(1)); + // verify state + assert_eq!(pool_delegatee.ledger.effective_balance(), staked_amount); + assert_eq!(pool_delegatee.bonded_stake(), staked_amount); + assert_eq!(pool_delegatee.available_to_bond(), 0); + assert_eq!(pool_delegatee.total_unbonded(), 0); + + // let a bunch of delegators join this pool + for i in 301..350 { + fund(&i, 500); + assert_ok!(Pools::join(RawOrigin::Signed(i).into(), (100 + i).into(), pool_id)); + staked_amount += 100 + i; + assert_eq!(held_balance(&i), 100 + i); + } + + let pool_delegatee = pool_delegatee.refresh().unwrap(); + assert_eq!(pool_delegatee.ledger.effective_balance(), staked_amount); + assert_eq!(pool_delegatee.bonded_stake(), staked_amount); + assert_eq!(pool_delegatee.available_to_bond(), 0); + assert_eq!(pool_delegatee.total_unbonded(), 0); + }); + } + + #[test] + fn bond_extra_to_pool() { + ExtBuilder::default().build_and_execute(|| { + let pool_id = create_pool(100, 200); + add_delegators_to_pool(pool_id, (300..310).collect(), 100); + let mut staked_amount = 200 + 100 * 10; + assert_eq!(get_pool_delegatee(pool_id).bonded_stake(), staked_amount); + + // bond extra to pool + for i in 300..310 { + assert_ok!(Pools::bond_extra( + RawOrigin::Signed(i).into(), + BondExtra::FreeBalance(50) + )); + staked_amount += 50; + assert_eq!(get_pool_delegatee(pool_id).bonded_stake(), staked_amount); + } + }); + } + + #[test] + fn claim_pool_rewards() { + ExtBuilder::default().build_and_execute(|| { + let creator = 100; + let creator_stake = 1000; + let pool_id = create_pool(creator, creator_stake); + add_delegators_to_pool(pool_id, (300..310).collect(), 100); + add_delegators_to_pool(pool_id, (310..320).collect(), 200); + let total_staked = creator_stake + 100 * 10 + 200 * 10; + + // give some rewards + let reward_acc = Pools::create_reward_account(pool_id); + let reward_amount = 1000; + fund(&reward_acc, reward_amount); + + // claim rewards + for i in 300..320 { + let pre_balance = Balances::free_balance(i); + let delegator_staked_balance = held_balance(&i); + // payout reward + assert_ok!(Pools::claim_payout(RawOrigin::Signed(i).into())); + + let reward = Balances::free_balance(i) - pre_balance; + assert_eq!(reward, delegator_staked_balance * reward_amount / total_staked); + } + + // payout creator + let pre_balance = Balances::free_balance(creator); + assert_ok!(Pools::claim_payout(RawOrigin::Signed(creator).into())); + // verify they are paid out correctly + let reward = Balances::free_balance(creator) - pre_balance; + assert_eq!(reward, creator_stake * reward_amount / total_staked); + + // reward account should only have left minimum balance after paying out everyone. + assert_eq!(Balances::free_balance(reward_acc), ExistentialDeposit::get()); + }); + } + + #[test] + fn withdraw_from_pool() { + ExtBuilder::default().build_and_execute(|| { + // initial era + start_era(1); + + let pool_id = create_pool(100, 1000); + let bond_amount = 200; + add_delegators_to_pool(pool_id, (300..310).collect(), bond_amount); + let total_staked = 1000 + bond_amount * 10; + let pool_acc = Pools::create_bonded_account(pool_id); + + start_era(2); + // nothing to release yet. + assert_noop!( + Pools::withdraw_unbonded(RawOrigin::Signed(301).into(), 301, 0), + PoolsError::::SubPoolsNotFound + ); + + // 301 wants to unbond 50 in era 2, withdrawable in era 5. + assert_ok!(Pools::unbond(RawOrigin::Signed(301).into(), 301, 50)); + + // 302 wants to unbond 100 in era 3, withdrawable in era 6. + start_era(3); + assert_ok!(Pools::unbond(RawOrigin::Signed(302).into(), 302, 100)); + + // 303 wants to unbond 200 in era 4, withdrawable in era 7. + start_era(4); + assert_ok!(Pools::unbond(RawOrigin::Signed(303).into(), 303, 200)); + + // active stake is now reduced.. + let expected_active = total_staked - (50 + 100 + 200); + assert!(eq_stake(pool_acc, total_staked, expected_active)); + + // nothing to withdraw at era 4 + for i in 301..310 { + assert_noop!( + Pools::withdraw_unbonded(RawOrigin::Signed(i).into(), i, 0), + PoolsError::::CannotWithdrawAny + ); + } + + assert!(eq_stake(pool_acc, total_staked, expected_active)); + + start_era(5); + // at era 5, 301 can withdraw. + + System::reset_events(); + let held_301 = held_balance(&301); + let free_301 = Balances::free_balance(301); + + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(301).into(), 301, 0)); + assert_eq!( + events_since_last_call(), + vec![Event::Released { delegatee: pool_acc, delegator: 301, amount: 50 }] + ); + assert_eq!( + pool_events_since_last_call(), + vec![PoolsEvent::Withdrawn { member: 301, pool_id, balance: 50, points: 50 }] + ); + assert_eq!(held_balance(&301), held_301 - 50); + assert_eq!(Balances::free_balance(301), free_301 + 50); + + start_era(7); + // era 7 both delegators can withdraw + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(302).into(), 302, 0)); + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(303).into(), 303, 0)); + + assert_eq!( + events_since_last_call(), + vec![ + Event::Released { delegatee: pool_acc, delegator: 302, amount: 100 }, + Event::Released { delegatee: pool_acc, delegator: 303, amount: 200 }, + ] + ); + assert_eq!( + pool_events_since_last_call(), + vec![ + PoolsEvent::Withdrawn { member: 302, pool_id, balance: 100, points: 100 }, + PoolsEvent::Withdrawn { member: 303, pool_id, balance: 200, points: 200 }, + PoolsEvent::MemberRemoved { pool_id: 1, member: 303 }, + ] + ); + + // 303 is killed + assert!(!Delegators::::contains_key(303)); + }); + } + + #[test] + fn pool_withdraw_unbonded() { + ExtBuilder::default().build_and_execute(|| { + // initial era + start_era(1); + let pool_id = create_pool(100, 1000); + add_delegators_to_pool(pool_id, (300..310).collect(), 200); + + start_era(2); + // 1000 tokens to be unbonded in era 5. + for i in 300..310 { + assert_ok!(Pools::unbond(RawOrigin::Signed(i).into(), i, 100)); + } + + start_era(3); + // 500 tokens to be unbonded in era 6. + for i in 300..310 { + assert_ok!(Pools::unbond(RawOrigin::Signed(i).into(), i, 50)); + } + + start_era(5); + // withdraw pool should withdraw 1000 tokens + assert_ok!(Pools::pool_withdraw_unbonded(RawOrigin::Signed(100).into(), pool_id, 0)); + assert_eq!(get_pool_delegatee(pool_id).total_unbonded(), 1000); + + start_era(6); + // should withdraw 500 more + assert_ok!(Pools::pool_withdraw_unbonded(RawOrigin::Signed(100).into(), pool_id, 0)); + assert_eq!(get_pool_delegatee(pool_id).total_unbonded(), 1000 + 500); + + start_era(7); + // Nothing to withdraw, still at 1500. + assert_ok!(Pools::pool_withdraw_unbonded(RawOrigin::Signed(100).into(), pool_id, 0)); + assert_eq!(get_pool_delegatee(pool_id).total_unbonded(), 1500); + }); + } + + #[test] + fn update_nominations() { + ExtBuilder::default().build_and_execute(|| { + start_era(1); + // can't nominate for non-existent pool + assert_noop!( + Pools::nominate(RawOrigin::Signed(100).into(), 1, vec![99]), + PoolsError::::PoolNotFound + ); + + let pool_id = create_pool(100, 1000); + let pool_acc = Pools::create_bonded_account(pool_id); + assert_ok!(Pools::nominate(RawOrigin::Signed(100).into(), 1, vec![20, 21, 22])); + assert!(Staking::status(&pool_acc) == Ok(StakerStatus::Nominator(vec![20, 21, 22]))); + + start_era(3); + assert_ok!(Pools::nominate(RawOrigin::Signed(100).into(), 1, vec![18, 19, 22])); + assert!(Staking::status(&pool_acc) == Ok(StakerStatus::Nominator(vec![18, 19, 22]))); + }); + } + + #[test] + fn destroy_pool() { + ExtBuilder::default().build_and_execute(|| { + start_era(1); + let creator = 100; + let creator_stake = 1000; + let pool_id = create_pool(creator, creator_stake); + add_delegators_to_pool(pool_id, (300..310).collect(), 200); + + start_era(3); + // lets destroy the pool + assert_ok!(Pools::set_state( + RawOrigin::Signed(creator).into(), + pool_id, + PoolState::Destroying + )); + assert_ok!(Pools::chill(RawOrigin::Signed(creator).into(), pool_id)); + + // unbond all members by the creator/admin + for i in 300..310 { + assert_ok!(Pools::unbond(RawOrigin::Signed(creator).into(), i, 200)); + } + + start_era(6); + // withdraw all members by the creator/admin + for i in 300..310 { + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(creator).into(), i, 0)); + } + + // unbond creator + assert_ok!(Pools::unbond(RawOrigin::Signed(creator).into(), creator, creator_stake)); + + start_era(9); + System::reset_events(); + // Withdraw self + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(creator).into(), creator, 0)); + assert_eq!( + pool_events_since_last_call(), + vec![ + PoolsEvent::Withdrawn { + member: creator, + pool_id, + balance: creator_stake, + points: creator_stake, + }, + PoolsEvent::MemberRemoved { pool_id, member: creator }, + PoolsEvent::Destroyed { pool_id }, + ] + ); + + // Make sure all data is cleaned up. + assert_eq!(Delegatees::::contains_key(Pools::create_bonded_account(pool_id)), false); + assert_eq!(Delegators::::contains_key(creator), false); + for i in 300..310 { + assert_eq!(Delegators::::contains_key(i), false); + } + }); + } + + #[test] + fn pool_slashed() { + ExtBuilder::default().build_and_execute(|| { + start_era(1); + let creator = 100; + let creator_stake = 500; + let pool_id = create_pool(creator, creator_stake); + let delegator_stake = 100; + add_delegators_to_pool(pool_id, (300..306).collect(), delegator_stake); + let pool_acc = Pools::create_bonded_account(pool_id); + + let total_staked = creator_stake + delegator_stake * 6; + assert_eq!(Staking::stake(&pool_acc).unwrap().total, total_staked); + + // lets unbond a delegator each in next eras (2, 3, 4). + start_era(2); + assert_ok!(Pools::unbond(RawOrigin::Signed(300).into(), 300, delegator_stake)); + + start_era(3); + assert_ok!(Pools::unbond(RawOrigin::Signed(301).into(), 301, delegator_stake)); + + start_era(4); + assert_ok!(Pools::unbond(RawOrigin::Signed(302).into(), 302, delegator_stake)); + System::reset_events(); + + // slash the pool at era 3 + assert_eq!( + BondedPools::::get(1).unwrap().points, + creator_stake + delegator_stake * 6 - delegator_stake * 3 + ); + pallet_staking::slashing::do_slash::( + &pool_acc, + 500, + &mut Default::default(), + &mut Default::default(), + 3, + ); + + assert_eq!( + pool_events_since_last_call(), + vec![ + // 300 did not get slashed as all as it unbonded in an era before slash. + // 301 got slashed 50% of 100 = 50. + PoolsEvent::UnbondingPoolSlashed { pool_id: 1, era: 6, balance: 50 }, + // 302 got slashed 50% of 100 = 50. + PoolsEvent::UnbondingPoolSlashed { pool_id: 1, era: 7, balance: 50 }, + // Rest of the pool slashed 50% of 800 = 400. + PoolsEvent::PoolSlashed { pool_id: 1, balance: 400 }, + ] + ); + + // slash is lazy and balance is still locked in user's accounts. + assert_eq!(held_balance(&creator), creator_stake); + for i in 300..306 { + assert_eq!(held_balance(&i), delegator_stake); + } + assert_eq!( + get_pool_delegatee(pool_id).ledger.effective_balance(), + Staking::total_stake(&pool_acc).unwrap() + ); + + // pending slash is book kept. + assert_eq!(get_pool_delegatee(pool_id).ledger.pending_slash, 500); + + // go in some distant future era. + start_era(10); + System::reset_events(); + + // 300 is not slashed and can withdraw all balance. + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(300).into(), 300, 1)); + assert_eq!( + events_since_last_call(), + vec![Event::Released { delegatee: pool_acc, delegator: 300, amount: 100 }] + ); + assert_eq!(get_pool_delegatee(pool_id).ledger.pending_slash, 500); + + // withdraw the other two delegators (301 and 302) who were unbonding. + for i in 301..=302 { + let pre_balance = Balances::free_balance(i); + let pre_pending_slash = get_pool_delegatee(pool_id).ledger.pending_slash; + assert_ok!(Pools::withdraw_unbonded(RawOrigin::Signed(i).into(), i, 0)); + assert_eq!( + events_since_last_call(), + vec![ + Event::Slashed { delegatee: pool_acc, delegator: i, amount: 50 }, + Event::Released { delegatee: pool_acc, delegator: i, amount: 50 }, + ] + ); + assert_eq!( + get_pool_delegatee(pool_id).ledger.pending_slash, + pre_pending_slash - 50 + ); + assert_eq!(held_balance(&i), 0); + assert_eq!(Balances::free_balance(i) - pre_balance, 50); + } + + // let's update all the slash + let slash_reporter = 99; + // give our reporter some balance. + fund(&slash_reporter, 100); + + for i in 303..306 { + let pre_pending_slash = get_pool_delegatee(pool_id).ledger.pending_slash; + assert_ok!(Pools::apply_slash(RawOrigin::Signed(slash_reporter).into(), i)); + + // each member is slashed 50% of 100 = 50. + assert_eq!( + get_pool_delegatee(pool_id).ledger.pending_slash, + pre_pending_slash - 50 + ); + // left with 50. + assert_eq!(held_balance(&i), 50); + } + // reporter is paid SlashRewardFraction of the slash, i.e. 10% of 50 = 5 + assert_eq!(Balances::free_balance(slash_reporter), 100 + 5 * 3); + // slash creator + assert_ok!(Pools::apply_slash(RawOrigin::Signed(slash_reporter).into(), creator)); + // all slash should be applied now. + assert_eq!(get_pool_delegatee(pool_id).ledger.pending_slash, 0); + // for creator, 50% of stake should be slashed (250), 10% of which should go to reporter + // (25). + assert_eq!(Balances::free_balance(slash_reporter), 115 + 25); + }); + } + + fn create_pool(creator: AccountId, amount: Balance) -> u32 { + fund(&creator, amount * 2); + assert_ok!(Pools::create( + RawOrigin::Signed(creator).into(), + amount, + creator, + creator, + creator + )); + + pallet_nomination_pools::LastPoolId::::get() + } + + fn add_delegators_to_pool(pool_id: u32, delegators: Vec, amount: Balance) { + for delegator in delegators { + fund(&delegator, amount * 2); + assert_ok!(Pools::join(RawOrigin::Signed(delegator).into(), amount, pool_id)); + } + } + + fn get_pool_delegatee(pool_id: u32) -> Delegatee { + get_delegatee(&Pools::create_bonded_account(pool_id)) + } +} diff --git a/substrate/frame/delegated-staking/src/types.rs b/substrate/frame/delegated-staking/src/types.rs new file mode 100644 index 000000000000..0dcdcc0659a4 --- /dev/null +++ b/substrate/frame/delegated-staking/src/types.rs @@ -0,0 +1,314 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! Basic types used in delegated staking. + +use super::*; +use frame_support::traits::DefensiveSaturating; + +/// The type of pot account being created. +#[derive(Encode, Decode)] +pub(crate) enum AccountType { + /// A proxy delegator account created for a nominator who migrated to a `delegatee` account. + /// + /// Funds for unmigrated `delegator` accounts of the `delegatee` are kept here. + ProxyDelegator, +} + +/// Information about delegation of a `delegator`. +#[derive(Default, Encode, Clone, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct Delegation { + /// The target of delegation. + pub delegatee: T::AccountId, + /// The amount delegated. + pub amount: BalanceOf, +} + +impl Delegation { + /// Get delegation of a `delegator`. + pub(crate) fn get(delegator: &T::AccountId) -> Option { + >::get(delegator) + } + + /// Create and return a new delegation instance. + pub(crate) fn from(delegatee: &T::AccountId, amount: BalanceOf) -> Self { + Delegation { delegatee: delegatee.clone(), amount } + } + + /// Ensure the delegator is either a new delegator or they are adding more delegation to the + /// existing delegatee. + /// + /// Delegators are prevented from delegating to multiple delegatees at the same time. + pub(crate) fn can_delegate(delegator: &T::AccountId, delegatee: &T::AccountId) -> bool { + Delegation::::get(delegator) + .map(|delegation| delegation.delegatee == delegatee.clone()) + .unwrap_or( + // all good if its a new delegator except it should not be an existing delegatee. + !>::contains_key(delegator), + ) + } + + /// Checked decrease of delegation amount. Consumes self and returns a new copy. + pub(crate) fn decrease_delegation(self, amount: BalanceOf) -> Option { + let updated_delegation = self.amount.checked_sub(&amount)?; + Some(Delegation::from(&self.delegatee, updated_delegation)) + } + + /// Checked increase of delegation amount. Consumes self and returns a new copy. + #[allow(unused)] + pub(crate) fn increase_delegation(self, amount: BalanceOf) -> Option { + let updated_delegation = self.amount.checked_add(&amount)?; + Some(Delegation::from(&self.delegatee, updated_delegation)) + } + + /// Save self to storage. If the delegation amount is zero, remove the delegation. + pub(crate) fn save_or_kill(self, key: &T::AccountId) { + // Clean up if no delegation left. + if self.amount == Zero::zero() { + >::remove(key); + return + } + + >::insert(key, self) + } +} + +/// Ledger of all delegations to a `Delegatee`. +/// +/// This keeps track of the active balance of the `delegatee` that is made up from the funds that +/// are currently delegated to this `delegatee`. It also tracks the pending slashes yet to be +/// applied among other things. +#[derive(Default, Clone, Encode, Decode, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +pub struct DelegateeLedger { + /// Where the reward should be paid out. + pub payee: T::AccountId, + /// Sum of all delegated funds to this `delegatee`. + #[codec(compact)] + pub total_delegated: BalanceOf, + /// Funds that are withdrawn from core staking but not released to delegator/s. It is a subset + /// of `total_delegated` and can never be greater than it. + /// + /// We need this register to ensure that the `delegatee` does not bond funds from delegated + /// funds that are withdrawn and should be claimed by delegators. + // FIXME(ank4n): Check/test about rebond: where delegator rebond what is unlocking. + #[codec(compact)] + pub unclaimed_withdrawals: BalanceOf, + /// Slashes that are not yet applied. This affects the effective balance of the `delegatee`. + #[codec(compact)] + pub pending_slash: BalanceOf, +} + +impl DelegateeLedger { + /// Create a new instance of `DelegateeLedger`. + pub(crate) fn new(reward_destination: &T::AccountId) -> Self { + DelegateeLedger { + payee: reward_destination.clone(), + total_delegated: Zero::zero(), + unclaimed_withdrawals: Zero::zero(), + pending_slash: Zero::zero(), + } + } + + /// Get `DelegateeLedger` from storage. + pub(crate) fn get(key: &T::AccountId) -> Option { + >::get(key) + } + + /// Save self to storage with the given key. + pub(crate) fn save(self, key: &T::AccountId) { + >::insert(key, self) + } + + /// Effective total balance of the `delegatee`. + /// + /// This takes into account any slashes reported to `Delegatee` but unapplied. + pub(crate) fn effective_balance(&self) -> BalanceOf { + defensive_assert!( + self.total_delegated >= self.pending_slash, + "slash cannot be higher than actual balance of delegator" + ); + + // pending slash needs to be burned and cannot be used for stake. + self.total_delegated.saturating_sub(self.pending_slash) + } + + /// Delegatee balance that can be staked/bonded in [`T::CoreStaking`]. + pub(crate) fn stakeable_balance(&self) -> BalanceOf { + self.effective_balance().saturating_sub(self.unclaimed_withdrawals) + } +} + +/// Wrapper around `DelegateeLedger` to provide additional functionality. +#[derive(Clone)] +pub struct Delegatee { + /// storage key + pub key: T::AccountId, + /// storage value + pub ledger: DelegateeLedger, +} + +impl Delegatee { + /// Get `Delegatee` from storage if it exists or return an error. + pub(crate) fn from(delegatee: &T::AccountId) -> Result, DispatchError> { + let ledger = DelegateeLedger::::get(delegatee).ok_or(Error::::NotDelegatee)?; + Ok(Delegatee { key: delegatee.clone(), ledger }) + } + + /// Remove funds that are withdrawn from [Config::CoreStaking] but not claimed by a delegator. + /// + /// Checked decrease of delegation amount from `total_delegated` and `unclaimed_withdrawals` + /// registers. Consumes self and returns a new instance of self if success. + pub(crate) fn remove_unclaimed_withdraw( + self, + amount: BalanceOf, + ) -> Result { + let new_total_delegated = self + .ledger + .total_delegated + .checked_sub(&amount) + .defensive_ok_or(ArithmeticError::Overflow)?; + let new_unclaimed_withdrawals = self + .ledger + .unclaimed_withdrawals + .checked_sub(&amount) + .defensive_ok_or(ArithmeticError::Overflow)?; + + Ok(Delegatee { + ledger: DelegateeLedger { + total_delegated: new_total_delegated, + unclaimed_withdrawals: new_unclaimed_withdrawals, + ..self.ledger + }, + ..self + }) + } + + /// Add funds that are withdrawn from [Config::CoreStaking] to be claimed by delegators later. + pub(crate) fn add_unclaimed_withdraw( + self, + amount: BalanceOf, + ) -> Result { + let new_unclaimed_withdrawals = self + .ledger + .unclaimed_withdrawals + .checked_add(&amount) + .defensive_ok_or(ArithmeticError::Overflow)?; + + Ok(Delegatee { + ledger: DelegateeLedger { + unclaimed_withdrawals: new_unclaimed_withdrawals, + ..self.ledger + }, + ..self + }) + } + + /// Amount that is delegated but not bonded yet. + /// + /// This importantly does not include `unclaimed_withdrawals` as those should not be bonded + /// again unless explicitly requested. + pub(crate) fn available_to_bond(&self) -> BalanceOf { + let bonded_stake = self.bonded_stake(); + let stakeable = self.ledger.stakeable_balance(); + + defensive_assert!( + stakeable >= bonded_stake, + "cannot be bonded with more than delegatee balance" + ); + + stakeable.saturating_sub(bonded_stake) + } + + /// Remove slashes from the `DelegateeLedger`. + pub(crate) fn remove_slash(self, amount: BalanceOf) -> Self { + let pending_slash = self.ledger.pending_slash.defensive_saturating_sub(amount); + let total_delegated = self.ledger.total_delegated.defensive_saturating_sub(amount); + + Delegatee { + ledger: DelegateeLedger { pending_slash, total_delegated, ..self.ledger }, + ..self + } + } + + /// Get the total stake of delegatee bonded in [`Config::CoreStaking`]. + pub(crate) fn bonded_stake(&self) -> BalanceOf { + T::CoreStaking::total_stake(&self.key).unwrap_or(Zero::zero()) + } + + /// Returns true if the delegatee is bonded in [`Config::CoreStaking`]. + pub(crate) fn is_bonded(&self) -> bool { + T::CoreStaking::stake(&self.key).is_ok() + } + + /// Returns the reward account registered by the delegatee. + pub(crate) fn reward_account(&self) -> &T::AccountId { + &self.ledger.payee + } + + /// Save self to storage. + pub(crate) fn save(self) { + let key = self.key; + self.ledger.save(&key) + } + + /// Save self and remove if no delegation left. + /// + /// Returns error if the delegate is in an unexpected state. + pub(crate) fn save_or_kill(self) -> Result<(), DispatchError> { + let key = self.key; + // see if delegate can be killed + if self.ledger.total_delegated == Zero::zero() { + ensure!( + self.ledger.unclaimed_withdrawals == Zero::zero() && + self.ledger.pending_slash == Zero::zero(), + Error::::BadState + ); + >::remove(key); + } else { + self.ledger.save(&key) + } + + Ok(()) + } + + /// Reloads self from storage. + #[cfg(test)] + pub(crate) fn refresh(&self) -> Result, DispatchError> { + Self::from(&self.key) + } + + /// Balance of `Delegatee` that is not bonded. + /// + /// This is similar to [Self::available_to_bond] except it also includes `unclaimed_withdrawals` + /// of `Delegatee`. + #[cfg(test)] + pub(crate) fn total_unbonded(&self) -> BalanceOf { + let bonded_stake = self.bonded_stake(); + + let net_balance = self.ledger.effective_balance(); + + defensive_assert!( + net_balance >= bonded_stake, + "cannot be bonded with more than the delegatee balance" + ); + + net_balance.saturating_sub(bonded_stake) + } +} diff --git a/substrate/frame/nomination-pools/src/adapter.rs b/substrate/frame/nomination-pools/src/adapter.rs new file mode 100644 index 000000000000..09c8fc488ad7 --- /dev/null +++ b/substrate/frame/nomination-pools/src/adapter.rs @@ -0,0 +1,301 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::*; +use sp_staking::delegation::DelegatedStakeInterface; + +/// An adapter trait that can support multiple staking strategies. +/// +/// Depending on which staking strategy we want to use, the staking logic can be slightly +/// different. Refer the two possible strategies currently: [`TransferStake`] and +/// [`DelegateStake`]. +pub trait StakeStrategy { + type Balance: frame_support::traits::tokens::Balance; + type AccountId: Clone + sp_std::fmt::Debug; + + fn bonding_duration() -> EraIndex; + fn current_era() -> EraIndex; + fn minimum_nominator_bond() -> Self::Balance; + + /// Transferable balance of the pool. + /// + /// This is the amount that can be withdrawn from the pool. + /// + /// Does not include reward account. + fn transferable_balance(id: PoolId) -> Self::Balance; + + /// Total balance of the pool including amount that is actively staked. + fn total_balance(id: PoolId) -> Self::Balance; + fn member_delegation_balance(member_account: &Self::AccountId) -> Self::Balance; + + fn active_stake(pool: PoolId) -> Self::Balance; + fn total_stake(pool: PoolId) -> Self::Balance; + + fn nominate(pool_id: PoolId, validators: Vec) -> DispatchResult; + + fn chill(pool_id: PoolId) -> DispatchResult; + + fn bond( + who: &Self::AccountId, + pool_id: PoolId, + amount: Self::Balance, + bond_type: BondType, + ) -> DispatchResult; + + fn unbond(pool_id: PoolId, amount: Self::Balance) -> DispatchResult; + + fn withdraw_unbonded(pool_id: PoolId, num_slashing_spans: u32) -> Result; + + fn member_withdraw( + who: &Self::AccountId, + pool: PoolId, + amount: Self::Balance, + ) -> DispatchResult; + + fn has_pending_slash(pool: PoolId) -> bool; + + fn member_slash( + who: &Self::AccountId, + pool: PoolId, + amount: Self::Balance, + maybe_reporter: Option, + ) -> DispatchResult; +} + +/// A staking strategy implementation that supports transfer based staking. +/// +/// In order to stake, this adapter transfers the funds from the member/delegator account to the +/// pool account and stakes through the pool account on `Staking`. +pub struct TransferStake(PhantomData<(T, Staking)>); + +impl, AccountId = T::AccountId>> + StakeStrategy for TransferStake +{ + type Balance = BalanceOf; + type AccountId = T::AccountId; + + fn bonding_duration() -> EraIndex { + Staking::bonding_duration() + } + fn current_era() -> EraIndex { + Staking::current_era() + } + fn minimum_nominator_bond() -> Staking::Balance { + Staking::minimum_nominator_bond() + } + + fn transferable_balance(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + T::Currency::balance(&pool_account).saturating_sub(Self::active_stake(pool)) + } + + fn total_balance(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + T::Currency::total_balance(&pool_account) + } + + fn member_delegation_balance(_member_account: &T::AccountId) -> Staking::Balance { + defensive!("delegation not supported"); + Zero::zero() + } + + fn active_stake(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::active_stake(&pool_account).unwrap_or_default() + } + + fn total_stake(pool: PoolId) -> Staking::Balance { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::total_stake(&pool_account).unwrap_or_default() + } + + fn nominate(pool_id: PoolId, validators: Vec) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::nominate(&pool_account, validators) + } + + fn chill(pool_id: PoolId) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::chill(&pool_account) + } + + fn bond( + who: &T::AccountId, + pool: PoolId, + amount: BalanceOf, + bond_type: BondType, + ) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool); + let reward_account = Pallet::::create_reward_account(pool); + + match bond_type { + BondType::Create => { + // first bond + T::Currency::transfer(who, &pool_account, amount, Preservation::Expendable)?; + Staking::bond(&pool_account, amount, &reward_account) + }, + BondType::Later => { + // additional bond + T::Currency::transfer(who, &pool_account, amount, Preservation::Preserve)?; + Staking::bond_extra(&pool_account, amount) + }, + } + } + + fn unbond(pool_id: PoolId, amount: Staking::Balance) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::unbond(&pool_account, amount) + } + + fn withdraw_unbonded(pool_id: PoolId, num_slashing_spans: u32) -> Result { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::withdraw_unbonded(pool_account, num_slashing_spans) + } + + fn member_withdraw(who: &T::AccountId, pool: PoolId, amount: BalanceOf) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool); + T::Currency::transfer(&pool_account, &who, amount, Preservation::Expendable)?; + + Ok(()) + } + + fn has_pending_slash(_pool: PoolId) -> bool { + // for transfer stake strategy, slashing is greedy + false + } + + fn member_slash( + _who: &T::AccountId, + _pool: PoolId, + _amount: Staking::Balance, + _maybe_reporter: Option, + ) -> DispatchResult { + Err(Error::::Defensive(DefensiveError::DelegationUnsupported).into()) + } +} + +/// A staking strategy implementation that supports delegation based staking. +/// +/// In this approach, first the funds are delegated from delegator to the pool account and later +/// staked with `Staking`. The advantage of this approach is that the funds are held in the +/// user account itself and not in the pool account. +pub struct DelegateStake(PhantomData<(T, Staking)>); + +impl< + T: Config, + Staking: DelegatedStakeInterface, AccountId = T::AccountId>, + > StakeStrategy for DelegateStake +{ + type Balance = BalanceOf; + type AccountId = T::AccountId; + + fn bonding_duration() -> EraIndex { + Staking::bonding_duration() + } + fn current_era() -> EraIndex { + Staking::current_era() + } + fn minimum_nominator_bond() -> Staking::Balance { + Staking::minimum_nominator_bond() + } + + fn transferable_balance(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::delegatee_balance(&pool_account).saturating_sub(Self::active_stake(pool)) + } + + fn total_balance(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::delegatee_balance(&pool_account) + } + + fn member_delegation_balance(member_account: &T::AccountId) -> Staking::Balance { + Staking::delegator_balance(member_account) + } + + fn active_stake(pool: PoolId) -> BalanceOf { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::active_stake(&pool_account).unwrap_or_default() + } + + fn total_stake(pool: PoolId) -> Staking::Balance { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::total_stake(&pool_account).unwrap_or_default() + } + + fn nominate(pool_id: PoolId, validators: Vec) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::nominate(&pool_account, validators) + } + + fn chill(pool_id: PoolId) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::chill(&pool_account) + } + + fn bond( + who: &T::AccountId, + pool: PoolId, + amount: BalanceOf, + bond_type: BondType, + ) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool); + + match bond_type { + BondType::Create => { + // first delegation + let reward_account = Pallet::::create_reward_account(pool); + Staking::delegate(who, &pool_account, &reward_account, amount) + }, + BondType::Later => { + // additional delegation + Staking::delegate_extra(who, &pool_account, amount) + }, + } + } + + fn unbond(pool_id: PoolId, amount: Staking::Balance) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::unbond(&pool_account, amount) + } + + fn withdraw_unbonded(pool_id: PoolId, num_slashing_spans: u32) -> Result { + let pool_account = Pallet::::create_bonded_account(pool_id); + Staking::withdraw_unbonded(pool_account, num_slashing_spans) + } + + fn member_withdraw(who: &T::AccountId, pool: PoolId, amount: BalanceOf) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::withdraw_delegation(&who, &pool_account, amount) + } + + fn has_pending_slash(pool: PoolId) -> bool { + // for transfer stake strategy, slashing is greedy + let pool_account = Pallet::::create_bonded_account(pool); + Staking::has_pending_slash(&pool_account) + } + + fn member_slash( + who: &T::AccountId, + pool: PoolId, + amount: Staking::Balance, + maybe_reporter: Option, + ) -> DispatchResult { + let pool_account = Pallet::::create_bonded_account(pool); + Staking::delegator_slash(&pool_account, who, amount, maybe_reporter) + } +} diff --git a/substrate/frame/nomination-pools/src/lib.rs b/substrate/frame/nomination-pools/src/lib.rs index f29a49a2b1b3..40c52db7c534 100644 --- a/substrate/frame/nomination-pools/src/lib.rs +++ b/substrate/frame/nomination-pools/src/lib.rs @@ -351,6 +351,7 @@ #![cfg_attr(not(feature = "std"), no_std)] +use adapter::StakeStrategy; use codec::Codec; use frame_support::{ defensive, defensive_assert, ensure, @@ -397,6 +398,7 @@ pub mod mock; #[cfg(test)] mod tests; +pub mod adapter; pub mod migration; pub mod weights; @@ -425,7 +427,7 @@ pub enum ConfigOp { } /// The type of bonding that can happen to a pool. -enum BondType { +pub enum BondType { /// Someone is bonding into the pool upon creation. Create, /// Someone is adding more funds later to this pool. @@ -545,9 +547,14 @@ impl PoolMember { /// Total balance of the member, both active and unbonding. /// Doesn't mutate state. - #[cfg(any(feature = "try-runtime", feature = "fuzzing", test, debug_assertions))] fn total_balance(&self) -> BalanceOf { - let pool = BondedPool::::get(self.pool_id).unwrap(); + let maybe_pool = BondedPool::::get(self.pool_id); + if maybe_pool.is_none() { + defensive!("pool should exist; qed"); + return Zero::zero(); + } + let pool = maybe_pool.expect("checked pool is not none; qed"); + let active_balance = pool.points_to_balance(self.active_points()); let sub_pools = match SubPoolsStorage::::get(self.pool_id) { @@ -991,8 +998,7 @@ impl BondedPool { /// /// This is often used for bonding and issuing new funds into the pool. fn balance_to_point(&self, new_funds: BalanceOf) -> BalanceOf { - let bonded_balance = - T::Staking::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + let bonded_balance = T::StakeAdapter::active_stake(self.id); Pallet::::balance_to_point(bonded_balance, self.points, new_funds) } @@ -1000,8 +1006,7 @@ impl BondedPool { /// /// This is often used for unbonding. fn points_to_balance(&self, points: BalanceOf) -> BalanceOf { - let bonded_balance = - T::Staking::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + let bonded_balance = T::StakeAdapter::active_stake(self.id); Pallet::::point_to_balance(bonded_balance, self.points, points) } @@ -1048,18 +1053,6 @@ impl BondedPool { self } - /// The pools balance that is transferable provided it is expendable by staking pallet. - fn transferable_balance(&self) -> BalanceOf { - let account = self.bonded_account(); - // Note on why we can't use `Currency::reducible_balance`: Since pooled account has a - // provider (staking pallet), the account can not be set expendable by - // `pallet-nomination-pool`. This means reducible balance always returns balance preserving - // ED in the account. What we want though is transferable balance given the account can be - // dusted. - T::Currency::balance(&account) - .saturating_sub(T::Staking::active_stake(&account).unwrap_or_default()) - } - fn is_root(&self, who: &T::AccountId) -> bool { self.roles.root.as_ref().map_or(false, |root| root == who) } @@ -1123,8 +1116,7 @@ impl BondedPool { fn ok_to_be_open(&self) -> Result<(), DispatchError> { ensure!(!self.is_destroying(), Error::::CanNotChangeState); - let bonded_balance = - T::Staking::active_stake(&self.bonded_account()).unwrap_or(Zero::zero()); + let bonded_balance = T::StakeAdapter::active_stake(self.id); ensure!(!bonded_balance.is_zero(), Error::::OverflowRisk); let points_to_balance_ratio_floor = self @@ -1253,28 +1245,14 @@ impl BondedPool { amount: BalanceOf, ty: BondType, ) -> Result, DispatchError> { - // Cache the value - let bonded_account = self.bonded_account(); - T::Currency::transfer( - who, - &bonded_account, - amount, - match ty { - BondType::Create => Preservation::Expendable, - BondType::Later => Preservation::Preserve, - }, - )?; // We must calculate the points issued *before* we bond who's funds, else points:balance // ratio will be wrong. let points_issued = self.issue(amount); - match ty { - BondType::Create => T::Staking::bond(&bonded_account, amount, &self.reward_account())?, - // The pool should always be created in such a way its in a state to bond extra, but if - // the active balance is slashed below the minimum bonded or the account cannot be - // found, we exit early. - BondType::Later => T::Staking::bond_extra(&bonded_account, amount)?, - } + // The pool should always be created in such a way it is in a state to bond extra, but if + // the active balance is slashed below the minimum bonded or the account cannot be + // found, we exit early. + T::StakeAdapter::bond(who, self.id, amount, ty)?; TotalValueLocked::::mutate(|tvl| { tvl.saturating_accrue(amount); }); @@ -1293,6 +1271,10 @@ impl BondedPool { }); }; } + + fn has_pending_slash(&self) -> bool { + T::StakeAdapter::has_pending_slash(self.id) + } } /// A reward pool. @@ -1564,8 +1546,8 @@ impl Get for TotalUnbondingPools { fn get() -> u32 { // NOTE: this may be dangerous in the scenario bonding_duration gets decreased because // we would no longer be able to decode `BoundedBTreeMap::, - // TotalUnbondingPools>`, which uses `TotalUnbondingPools` as the bound - T::Staking::bonding_duration() + T::PostUnbondingPoolsWindow::get() + // TotalUnbondingPools>`, werh uses `TotalUnbondingPools` as the bound + T::StakeAdapter::bonding_duration() + T::PostUnbondingPoolsWindow::get() } } @@ -1641,9 +1623,6 @@ pub mod pallet { /// Infallible method for converting `U256` to `Currency::Balance`. type U256ToBalance: Convert>; - /// The interface for nominating. - type Staking: StakingInterface, AccountId = Self::AccountId>; - /// The amount of eras a `SubPools::with_era` pool can exist before it gets merged into the /// `SubPools::no_era` pool. In other words, this is the amount of eras a member will be /// able to withdraw from an unbonding pool which is guaranteed to have the correct ratio of @@ -1653,6 +1632,12 @@ pub mod pallet { /// The maximum length, in bytes, that a pools metadata maybe. type MaxMetadataLen: Get; + + /// Staking adapter to support different staking strategies. + type StakeAdapter: adapter::StakeStrategy< + AccountId = Self::AccountId, + Balance = BalanceOf, + >; } /// The sum of funds across all pools. @@ -1789,7 +1774,7 @@ pub mod pallet { /// Events of this pallet. #[pallet::event] - #[pallet::generate_deposit(pub(crate) fn deposit_event)] + #[pallet::generate_deposit(pub (crate) fn deposit_event)] pub enum Event { /// A pool has been created. Created { depositor: T::AccountId, pool_id: PoolId }, @@ -1943,6 +1928,8 @@ pub mod pallet { BondExtraRestricted, /// No imbalance in the ED deposit for the pool. NothingToAdjust, + /// No slash pending that can be applied to the member. + NothingToSlash, } #[derive(Encode, Decode, PartialEq, TypeInfo, PalletError, RuntimeDebug)] @@ -1958,6 +1945,8 @@ pub mod pallet { /// The bonded account should only be killed by the staking system when the depositor is /// withdrawing BondedStashKilledPrematurely, + /// Using stake strategy that does not support delegation + DelegationUnsupported, } impl From for Error { @@ -2051,9 +2040,9 @@ pub mod pallet { // of just once, in the spirit reusing code. #[pallet::call_index(1)] #[pallet::weight( - T::WeightInfo::bond_extra_transfer() - .max(T::WeightInfo::bond_extra_other()) - )] + T::WeightInfo::bond_extra_transfer() + .max(T::WeightInfo::bond_extra_other()) + )] pub fn bond_extra(origin: OriginFor, extra: BondExtra>) -> DispatchResult { let who = ensure_signed(origin)?; Self::do_bond_extra(who.clone(), who, extra) @@ -2134,12 +2123,12 @@ pub mod pallet { &mut reward_pool, )?; - let current_era = T::Staking::current_era(); - let unbond_era = T::Staking::bonding_duration().saturating_add(current_era); + let current_era = T::StakeAdapter::current_era(); + let unbond_era = T::StakeAdapter::bonding_duration().saturating_add(current_era); // Unbond in the actual underlying nominator. let unbonding_balance = bonded_pool.dissolve(unbonding_points); - T::Staking::unbond(&bonded_pool.bonded_account(), unbonding_balance)?; + T::StakeAdapter::unbond(bonded_pool.id, unbonding_balance)?; // Note that we lazily create the unbonding pools here if they don't already exist let mut sub_pools = SubPoolsStorage::::get(member.pool_id) @@ -2190,7 +2179,7 @@ pub mod pallet { /// would probably see an error like `NoMoreChunks` emitted from the staking system when /// they attempt to unbond. #[pallet::call_index(4)] - #[pallet::weight(T::WeightInfo::pool_withdraw_unbonded(*num_slashing_spans))] + #[pallet::weight(T::WeightInfo::pool_withdraw_unbonded(* num_slashing_spans))] pub fn pool_withdraw_unbonded( origin: OriginFor, pool_id: PoolId, @@ -2202,7 +2191,7 @@ pub mod pallet { // For now we only allow a pool to withdraw unbonded if its not destroying. If the pool // is destroying then `withdraw_unbonded` can be used. ensure!(pool.state != PoolState::Destroying, Error::::NotDestroying); - T::Staking::withdraw_unbonded(pool.bonded_account(), num_slashing_spans)?; + T::StakeAdapter::withdraw_unbonded(pool_id, num_slashing_spans)?; Ok(()) } @@ -2228,8 +2217,8 @@ pub mod pallet { /// If the target is the depositor, the pool will be destroyed. #[pallet::call_index(5)] #[pallet::weight( - T::WeightInfo::withdraw_unbonded_kill(*num_slashing_spans) - )] + T::WeightInfo::withdraw_unbonded_kill(* num_slashing_spans) + )] pub fn withdraw_unbonded( origin: OriginFor, member_account: AccountIdLookupOf, @@ -2239,13 +2228,18 @@ pub mod pallet { let member_account = T::Lookup::lookup(member_account)?; let mut member = PoolMembers::::get(&member_account).ok_or(Error::::PoolMemberNotFound)?; - let current_era = T::Staking::current_era(); + let current_era = T::StakeAdapter::current_era(); let bonded_pool = BondedPool::::get(member.pool_id) .defensive_ok_or::>(DefensiveError::PoolNotFound.into())?; let mut sub_pools = SubPoolsStorage::::get(member.pool_id).ok_or(Error::::SubPoolsNotFound)?; + if bonded_pool.has_pending_slash() { + // apply slash if any before withdraw. + let _ = Self::do_apply_slash(&member_account, None); + } + bonded_pool.ok_to_withdraw_unbonded_with(&caller, &member_account)?; // NOTE: must do this after we have done the `ok_to_withdraw_unbonded_other_with` check. @@ -2255,7 +2249,7 @@ pub mod pallet { // Before calculating the `balance_to_unbond`, we call withdraw unbonded to ensure the // `transferrable_balance` is correct. let stash_killed = - T::Staking::withdraw_unbonded(bonded_pool.bonded_account(), num_slashing_spans)?; + T::StakeAdapter::withdraw_unbonded(bonded_pool.id, num_slashing_spans)?; // defensive-only: the depositor puts enough funds into the stash so that it will only // be destroyed when they are leaving. @@ -2288,15 +2282,10 @@ pub mod pallet { // don't exist. This check is also defensive in cases where the unbond pool does not // update its balance (e.g. a bug in the slashing hook.) We gracefully proceed in // order to ensure members can leave the pool and it can be destroyed. - .min(bonded_pool.transferable_balance()); + .min(T::StakeAdapter::transferable_balance(bonded_pool.id)); - T::Currency::transfer( - &bonded_pool.bonded_account(), - &member_account, - balance_to_unbond, - Preservation::Expendable, - ) - .defensive()?; + T::StakeAdapter::member_withdraw(&member_account, bonded_pool.id, balance_to_unbond) + .defensive()?; Self::deposit_event(Event::::Withdrawn { member: member_account.clone(), @@ -2426,7 +2415,7 @@ pub mod pallet { Error::::MinimumBondNotMet ); - T::Staking::nominate(&bonded_pool.bonded_account(), validators) + T::StakeAdapter::nominate(bonded_pool.id, validators) } /// Set a new state for the pool. @@ -2614,12 +2603,12 @@ pub mod pallet { .active_points(); if bonded_pool.points_to_balance(depositor_points) >= - T::Staking::minimum_nominator_bond() + T::StakeAdapter::minimum_nominator_bond() { ensure!(bonded_pool.can_nominate(&who), Error::::NotNominator); } - T::Staking::chill(&bonded_pool.bonded_account()) + T::StakeAdapter::chill(bonded_pool.id) } /// `origin` bonds funds from `extra` for some pool member `member` into their respective @@ -2633,9 +2622,9 @@ pub mod pallet { /// `PermissionlessAll` or `PermissionlessCompound`. #[pallet::call_index(14)] #[pallet::weight( - T::WeightInfo::bond_extra_transfer() - .max(T::WeightInfo::bond_extra_other()) - )] + T::WeightInfo::bond_extra_transfer() + .max(T::WeightInfo::bond_extra_other()) + )] pub fn bond_extra_other( origin: OriginFor, member: AccountIdLookupOf, @@ -2819,6 +2808,19 @@ pub mod pallet { Ok(()) } + + /// Apply a pending slash on a member. + #[pallet::call_index(23)] + // FIXME(ank4n): fix weight info. + #[pallet::weight(T::WeightInfo::set_commission_claim_permission())] + pub fn apply_slash( + origin: OriginFor, + member_account: AccountIdLookupOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + let member_account = T::Lookup::lookup(member_account)?; + Self::do_apply_slash(&member_account, Some(who)) + } } #[pallet::hooks] @@ -2834,7 +2836,7 @@ pub mod pallet { "Minimum points to balance ratio must be greater than 0" ); assert!( - T::Staking::bonding_duration() < TotalUnbondingPools::::get(), + T::StakeAdapter::bonding_duration() < TotalUnbondingPools::::get(), "There must be more unbonding pools then the bonding duration / so a slash can be applied to relevant unbonding pools. (We assume / the bonding duration > slash deffer duration.", @@ -2852,7 +2854,7 @@ impl Pallet { /// It is essentially `max { MinNominatorBond, MinCreateBond, MinJoinBond }`, where the former /// is coming from the staking pallet and the latter two are configured in this pallet. pub fn depositor_min_bond() -> BalanceOf { - T::Staking::minimum_nominator_bond() + T::StakeAdapter::minimum_nominator_bond() .max(MinCreateBond::::get()) .max(MinJoinBond::::get()) .max(T::Currency::minimum_balance()) @@ -2888,7 +2890,7 @@ impl Pallet { "bonded account of dissolving pool should have no consumers" ); defensive_assert!( - T::Staking::total_stake(&bonded_account).unwrap_or_default() == Zero::zero(), + T::StakeAdapter::total_stake(bonded_pool.id) == Zero::zero(), "dissolving pool should not have any stake in the staking pallet" ); @@ -2911,11 +2913,12 @@ impl Pallet { "could not transfer all amount to depositor while dissolving pool" ); defensive_assert!( - T::Currency::total_balance(&bonded_pool.bonded_account()) == Zero::zero(), + T::StakeAdapter::total_balance(bonded_pool.id) == Zero::zero(), "dissolving pool should not have any balance" ); // NOTE: Defensively force set balance to zero. T::Currency::set_balance(&reward_account, Zero::zero()); + // fixme(ank4n): Can't do this with delegated? T::Currency::set_balance(&bonded_pool.bonded_account(), Zero::zero()); Self::deposit_event(Event::::Destroyed { pool_id: bonded_pool.id }); @@ -3298,6 +3301,33 @@ impl Pallet { Ok(()) } + fn do_apply_slash( + member_account: &T::AccountId, + reporter: Option, + ) -> DispatchResult { + // calculate points to be slashed. + let member = + PoolMembers::::get(&member_account).ok_or(Error::::PoolMemberNotFound)?; + ensure!(T::StakeAdapter::has_pending_slash(member.pool_id), Error::::NothingToSlash); + + let delegated_balance = T::StakeAdapter::member_delegation_balance(&member_account); + let current_balance = member.total_balance(); + defensive_assert!( + delegated_balance >= current_balance, + "delegated balance should always be greater or equal to current balance" + ); + + // if nothing to slash, return error. + ensure!(delegated_balance > current_balance, Error::::NothingToSlash); + + T::StakeAdapter::member_slash( + &member_account, + member.pool_id, + delegated_balance.defensive_saturating_sub(current_balance), + reporter, + ) + } + /// Apply freeze on reward account to restrict it from going below ED. pub(crate) fn freeze_pool_deposit(reward_acc: &T::AccountId) -> DispatchResult { T::Currency::set_freeze( @@ -3461,8 +3491,7 @@ impl Pallet { pool is being destroyed and the depositor is the last member", ); - expected_tvl += - T::Staking::total_stake(&bonded_pool.bonded_account()).unwrap_or_default(); + expected_tvl += T::StakeAdapter::total_stake(bonded_pool.id); Ok(()) })?; @@ -3487,22 +3516,21 @@ impl Pallet { } for (pool_id, _pool) in BondedPools::::iter() { - let pool_account = Pallet::::create_bonded_account(pool_id); let subs = SubPoolsStorage::::get(pool_id).unwrap_or_default(); let sum_unbonding_balance = subs.sum_unbonding_balance(); - let bonded_balance = T::Staking::active_stake(&pool_account).unwrap_or_default(); - let total_balance = T::Currency::total_balance(&pool_account); + let bonded_balance = T::StakeAdapter::active_stake(pool_id); + let total_balance = T::StakeAdapter::total_balance(pool_id); assert!( - total_balance >= bonded_balance + sum_unbonding_balance, - "faulty pool: {:?} / {:?}, total_balance {:?} >= bonded_balance {:?} + sum_unbonding_balance {:?}", - pool_id, - _pool, - total_balance, - bonded_balance, - sum_unbonding_balance - ); + total_balance >= bonded_balance + sum_unbonding_balance, + "faulty pool: {:?} / {:?}, total_balance {:?} >= bonded_balance {:?} + sum_unbonding_balance {:?}", + pool_id, + _pool, + total_balance, + bonded_balance, + sum_unbonding_balance + ); } // Warn if any pool has incorrect ED frozen. We don't want to fail hard as this could be a @@ -3525,21 +3553,21 @@ impl Pallet { pub fn check_ed_imbalance() -> Result<(), DispatchError> { let mut failed: u32 = 0; BondedPools::::iter_keys().for_each(|id| { - let reward_acc = Self::create_reward_account(id); - let frozen_balance = - T::Currency::balance_frozen(&FreezeReason::PoolMinBalance.into(), &reward_acc); - - let expected_frozen_balance = T::Currency::minimum_balance(); - if frozen_balance != expected_frozen_balance { - failed += 1; - log::warn!( + let reward_acc = Self::create_reward_account(id); + let frozen_balance = + T::Currency::balance_frozen(&FreezeReason::PoolMinBalance.into(), &reward_acc); + + let expected_frozen_balance = T::Currency::minimum_balance(); + if frozen_balance != expected_frozen_balance { + failed += 1; + log::warn!( "pool {:?} has incorrect ED frozen that can result from change in ED. Expected = {:?}, Actual = {:?}", id, expected_frozen_balance, frozen_balance, ); - } - }); + } + }); ensure!(failed == 0, "Some pools do not have correct ED frozen"); Ok(()) @@ -3572,7 +3600,7 @@ impl Pallet { let (current_reward_counter, _) = reward_pool .current_reward_counter(pool_member.pool_id, bonded_pool.points, commission) .ok()?; - return pool_member.pending_rewards(current_reward_counter).ok() + return pool_member.pending_rewards(current_reward_counter).ok(); } } @@ -3595,8 +3623,7 @@ impl Pallet { /// If the pool ID does not exist, returns 0 ratio balance to points. Used by runtime API. pub fn api_balance_to_points(pool_id: PoolId, new_funds: BalanceOf) -> BalanceOf { if let Some(pool) = BondedPool::::get(pool_id) { - let bonded_balance = - T::Staking::active_stake(&pool.bonded_account()).unwrap_or(Zero::zero()); + let bonded_balance = T::StakeAdapter::active_stake(pool.id); Pallet::::balance_to_point(bonded_balance, pool.points, new_funds) } else { Zero::zero() diff --git a/substrate/frame/nomination-pools/src/migration.rs b/substrate/frame/nomination-pools/src/migration.rs index ca9c0874a83c..ba62ff96011b 100644 --- a/substrate/frame/nomination-pools/src/migration.rs +++ b/substrate/frame/nomination-pools/src/migration.rs @@ -1023,10 +1023,7 @@ mod helpers { pub(crate) fn calculate_tvl_by_total_stake() -> BalanceOf { BondedPools::::iter() - .map(|(id, inner)| { - T::Staking::total_stake(&BondedPool { id, inner: inner.clone() }.bonded_account()) - .unwrap_or_default() - }) + .map(|(id, _inner)| T::StakeAdapter::total_stake(id)) .reduce(|acc, total_balance| acc + total_balance) .unwrap_or_default() } diff --git a/substrate/frame/nomination-pools/src/mock.rs b/substrate/frame/nomination-pools/src/mock.rs index 686759604c23..6bac1d6c1428 100644 --- a/substrate/frame/nomination-pools/src/mock.rs +++ b/substrate/frame/nomination-pools/src/mock.rs @@ -190,6 +190,10 @@ impl sp_staking::StakingInterface for StakingMock { } } + fn update_payee(_: &Self::AccountId, _: &Self::AccountId) { + unimplemented!("method currently not used in testing") + } + fn election_ongoing() -> bool { unimplemented!("method currently not used in testing") } @@ -220,6 +224,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)] @@ -297,12 +305,12 @@ impl pools::Config for Runtime { type RewardCounter = RewardCounter; type BalanceToU256 = BalanceToU256; type U256ToBalance = U256ToBalance; - type Staking = StakingMock; type PostUnbondingPoolsWindow = PostUnbondingPoolsWindow; type PalletId = PoolsPalletId; type MaxMetadataLen = MaxMetadataLen; type MaxUnbonding = MaxUnbonding; type MaxPointsToBalance = frame_support::traits::ConstU8<10>; + type StakeAdapter = adapter::TransferStake; } type Block = frame_system::mocking::MockBlock; diff --git a/substrate/frame/nomination-pools/test-staking/src/mock.rs b/substrate/frame/nomination-pools/test-staking/src/mock.rs index 22939ff5e238..227f5ef5d596 100644 --- a/substrate/frame/nomination-pools/test-staking/src/mock.rs +++ b/substrate/frame/nomination-pools/test-staking/src/mock.rs @@ -180,12 +180,12 @@ impl pallet_nomination_pools::Config for Runtime { type RewardCounter = FixedU128; type BalanceToU256 = BalanceToU256; type U256ToBalance = U256ToBalance; - type Staking = Staking; type PostUnbondingPoolsWindow = PostUnbondingPoolsWindow; type MaxMetadataLen = ConstU32<256>; type MaxUnbonding = ConstU32<8>; type MaxPointsToBalance = ConstU8<10>; type PalletId = PoolsPalletId; + type StakeAdapter = pallet_nomination_pools::adapter::TransferStake; } type Block = frame_system::mocking::MockBlock; diff --git a/substrate/frame/staking/src/ledger.rs b/substrate/frame/staking/src/ledger.rs index 9461daefed65..a0907f77e014 100644 --- a/substrate/frame/staking/src/ledger.rs +++ b/substrate/frame/staking/src/ledger.rs @@ -31,15 +31,12 @@ //! performed through the methods exposed by the [`StakingLedger`] implementation in order to ensure //! state consistency. -use frame_support::{ - defensive, ensure, - traits::{Defensive, LockableCurrency, WithdrawReasons}, -}; -use sp_staking::StakingAccount; +use frame_support::{defensive, ensure, traits::Defensive}; +use sp_staking::{StakingAccount, StakingUnsafe}; 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, }; #[cfg(any(feature = "runtime-benchmarks", test))] @@ -59,12 +56,6 @@ impl StakingLedger { } /// Returns a new instance of a staking ledger. - /// - /// The [`Ledger`] storage is not mutated. In order to store, `StakingLedger::update` must be - /// called on the returned staking ledger. - /// - /// Note: as the controller accounts are being deprecated, the stash account is the same as the - /// controller account. pub fn new(stash: T::AccountId, stake: BalanceOf) -> Self { Self { stash: stash.clone(), @@ -187,7 +178,8 @@ impl StakingLedger { return Err(Error::::NotStash) } - T::Currency::set_lock(STAKING_ID, &self.stash, self.total, WithdrawReasons::all()); + Pallet::::update_lock(&self.stash, self.total).map_err(|_| Error::::BadState)?; + Ledger::::insert( &self.controller().ok_or_else(|| { defensive!("update called on a ledger that is not bonded."); @@ -204,22 +196,30 @@ 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); + } + + if Pallet::::restrict_reward_destination(&self.stash, payee.clone()) { + return Err(Error::::RewardDestinationRestricted); } + + >::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); } + + if Pallet::::restrict_reward_destination(&self.stash, payee.clone()) { + return Err(Error::::RewardDestinationRestricted); + } + + >::insert(&self.stash, payee); + Ok(()) } /// Sets the ledger controller to its stash. @@ -252,7 +252,7 @@ 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); + Pallet::::force_release(&ledger.stash); Ledger::::remove(controller); >::remove(&stash); diff --git a/substrate/frame/staking/src/lib.rs b/substrate/frame/staking/src/lib.rs index f5b7e3eca3de..aeae8e80fb83 100644 --- a/substrate/frame/staking/src/lib.rs +++ b/substrate/frame/staking/src/lib.rs @@ -506,19 +506,45 @@ enum LedgerIntegrityState { } impl StakingLedger { - /// Remove entries from `unlocking` that are sufficiently old and reduce the - /// total by the sum of their balances. - fn consolidate_unlocked(self, current_era: EraIndex) -> Self { + /// Remove entries from `unlocking` that are sufficiently old and optionally upto a given limit. + /// Reduce the total by the unlocked amount. + fn consolidate_unlocked( + self, + current_era: EraIndex, + maybe_limit: Option>, + ) -> Self { let mut total = self.total; + let mut unlocked = BalanceOf::::zero(); + + // see if there is a limit else default to total value of the ledger which implies no limit. + let limit = maybe_limit.unwrap_or(total); + + // remove chunks that are unlocking let unlocking: BoundedVec<_, _> = self .unlocking .into_iter() - .filter(|chunk| { - if chunk.era > current_era { - true + .filter_map(|chunk| { + // keep the chunks if they are from a future era or we have unlocked upto the limit + if chunk.era > current_era || limit == unlocked { + defensive_assert!(limit >= unlocked, "unlocked should never exceed limit"); + Some(chunk) } else { - total = total.saturating_sub(chunk.value); - false + // we remove chunks that are old enough until we reach limit. + let max_unlock = limit - unlocked; + if chunk.value <= max_unlock { + // unlock all and filter out + total = total.saturating_sub(chunk.value); + unlocked = unlocked.saturating_add(chunk.value); + None + } else { + // keep the leftover amount in the chunk + total = total.saturating_sub(max_unlock); + unlocked = unlocked.saturating_add(max_unlock); + Some(UnlockChunk { + value: chunk.value.saturating_sub(max_unlock), + era: chunk.era, + }) + } } }) .collect::>() diff --git a/substrate/frame/staking/src/pallet/impls.rs b/substrate/frame/staking/src/pallet/impls.rs index 2f43e4847e45..027bfa6e66dc 100644 --- a/substrate/frame/staking/src/pallet/impls.rs +++ b/substrate/frame/staking/src/pallet/impls.rs @@ -28,14 +28,16 @@ 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}, + traits::{ + Bounded, CheckedSub, Convert, One, SaturatedConversion, Saturating, StaticLookup, Zero, + }, Perbill, Percent, }; use sp_staking::{ @@ -43,7 +45,7 @@ use sp_staking::{ offence::{DisableStrategy, OffenceDetails, OnOffenceHandler}, EraIndex, OnStakingUpdate, Page, SessionIndex, Stake, StakingAccount::{self, Controller, Stash}, - StakingInterface, + StakingInterface, StakingUnsafe, }; use sp_std::prelude::*; @@ -58,6 +60,7 @@ use super::pallet::*; #[cfg(feature = "try-runtime")] use frame_support::ensure; +use frame_support::traits::WithdrawReasons; #[cfg(any(test, feature = "try-runtime"))] use sp_runtime::TryRuntimeError; @@ -149,17 +152,52 @@ 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()))?; + + let extra = if Self::is_virtual_nominator(stash) { + additional + } else { + // additional amount or actual balance of stash whichever is lower. + additional.min( + Self::stakeable_balance(stash) + .checked_sub(&ledger.total) + .ok_or(sp_runtime::ArithmeticError::Overflow)?, + ) + }; + + 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: stash.clone(), amount: extra }); + + Ok(()) + } + pub(super) fn do_withdraw_unbonded( controller: &T::AccountId, num_slashing_spans: u32, + maybe_amount: Option>, ) -> Result { let mut ledger = Self::ledger(Controller(controller.clone()))?; let (stash, old_total) = (ledger.stash.clone(), ledger.total); if let Some(current_era) = Self::current_era() { - ledger = ledger.consolidate_unlocked(current_era) + ledger = ledger.consolidate_unlocked(current_era, maybe_amount) } let new_total = ledger.total; + if let Some(amount) = maybe_amount { + ensure!(old_total.saturating_sub(new_total) == amount, Error::::NotEnoughFunds); + }; let used_weight = if ledger.unlocking.is_empty() && ledger.active < T::Currency::minimum_balance() { // This account must have called `unbond()` with some value that caused the active @@ -1132,6 +1170,45 @@ impl Pallet { ) -> Exposure> { EraInfo::::get_full_exposure(era, account) } + + /// Balance that can be staked in the pallet. Includes already staked balance. + pub(crate) fn stakeable_balance(who: &T::AccountId) -> BalanceOf { + T::Currency::free_balance(who) + } + + /// Whether the passed reward destination is restricted for the given account. + /// + /// Virtual nominators are not allowed to compound their rewards as this pallet does not manage + /// locks for them. For external pallets that manage the virtual bond, it is their + /// responsibility to distribute the reward and re-bond them. + /// + /// Conservatively, we expect them to always set the reward destination to a non stash account. + pub(crate) fn restrict_reward_destination( + who: &T::AccountId, + reward_destination: RewardDestination, + ) -> bool { + Self::is_virtual_nominator(who) && + match reward_destination { + RewardDestination::Account(payee) => payee == *who, + _ => true, + } + } + + pub(crate) fn is_virtual_nominator(who: &T::AccountId) -> bool { + VirtualNominators::::contains_key(who) + } + + pub(crate) fn update_lock( + who: &T::AccountId, + amount: BalanceOf, + ) -> sp_runtime::DispatchResult { + // Skip locking virtual nominators. They are handled by external pallets. + if !Self::is_virtual_nominator(who) { + T::Currency::set_lock(crate::STAKING_ID, who, amount, WithdrawReasons::all()); + } + + Ok(()) + } } impl Pallet { @@ -1748,6 +1825,12 @@ impl StakingInterface for Pallet { .map(|_| ()) } + fn update_payee(stash: &Self::AccountId, reward_acc: &Self::AccountId) -> DispatchResult { + // 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. @@ -1858,6 +1941,40 @@ impl StakingInterface for Pallet { T::MaxExposurePageSize::get() } } + + fn slash_reward_fraction() -> Perbill { + SlashRewardFraction::::get() + } +} + +impl StakingUnsafe for Pallet { + fn force_release(who: &Self::AccountId) { + T::Currency::remove_lock(crate::STAKING_ID, who) + } + + fn virtual_bond( + who: &Self::AccountId, + value: Self::Balance, + payee: &Self::AccountId, + ) -> DispatchResult { + if StakingLedger::::is_bonded(StakingAccount::Stash(who.clone())) { + return Err(Error::::AlreadyBonded.into()) + } + + frame_system::Pallet::::inc_consumers(&who).map_err(|_| Error::::BadState)?; + + // mark who as a virtual nominator + VirtualNominators::::insert(who, ()); + + Self::deposit_event(Event::::Bonded { stash: who.clone(), amount: value }); + let ledger = StakingLedger::::new(who.clone(), value); + + // You're auto-bonded forever, here. We might improve this by only bonding when + // you actually validate/nominate and remove once you unbond __everything__. + ledger.bond(RewardDestination::Account(payee.clone()))?; + + Ok(()) + } } #[cfg(any(test, feature = "try-runtime"))] diff --git a/substrate/frame/staking/src/pallet/mod.rs b/substrate/frame/staking/src/pallet/mod.rs index 2e5b3aa7b873..5bb7efe31acb 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, }; @@ -103,6 +103,7 @@ pub mod pallet { + From + TypeInfo + MaxEncodedLen; + /// Time used for computing era duration. /// /// It is guaranteed to start being called from the first `on_finalize`. Thus value at @@ -379,6 +380,16 @@ pub mod pallet { pub type Nominators = CountedStorageMap<_, Twox64Concat, T::AccountId, Nominations>; + /// Nominators 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. + // TODO(ank4n): Can we keep this entry in `Ledger`? + #[pallet::storage] + pub type VirtualNominators = 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. @@ -709,7 +720,7 @@ pub mod pallet { status ); assert!( - T::Currency::free_balance(stash) >= balance, + Pallet::::stakeable_balance(stash) >= balance, "Stash does not have enough balance to bond." ); frame_support::assert_ok!(>::bond( @@ -856,6 +867,10 @@ pub mod pallet { BoundNotMet, /// Used when attempting to use deprecated controller account logic. ControllerDeprecated, + /// Provided reward destination is not allowed. + RewardDestinationRestricted, + /// Not enough funds available to withdraw + NotEnoughFunds, /// Cannot reset a ledger. CannotRestoreLedger, } @@ -951,8 +966,7 @@ pub mod pallet { } frame_system::Pallet::::inc_consumers(&stash).map_err(|_| Error::::BadState)?; - - let stash_balance = T::Currency::free_balance(&stash); + let stash_balance = Self::stakeable_balance(&stash); let value = value.min(stash_balance); Self::deposit_event(Event::::Bonded { stash: stash.clone(), amount: value }); let ledger = StakingLedger::::new(stash.clone(), value); @@ -985,29 +999,8 @@ 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 @@ -1047,7 +1040,11 @@ pub mod pallet { if unlocking == T::MaxUnlockingChunks::get() as usize { let real_num_slashing_spans = Self::slashing_spans(&controller).map_or(0, |s| s.iter().count()); - Some(Self::do_withdraw_unbonded(&controller, real_num_slashing_spans as u32)?) + Some(Self::do_withdraw_unbonded( + &controller, + real_num_slashing_spans as u32, + None, + )?) } else { None } @@ -1058,7 +1055,6 @@ pub mod pallet { let mut ledger = Self::ledger(Controller(controller))?; let mut value = value.min(ledger.active); let stash = ledger.stash.clone(); - ensure!( ledger.unlocking.len() < T::MaxUnlockingChunks::get() as usize, Error::::NoMoreChunks, @@ -1151,7 +1147,7 @@ pub mod pallet { ) -> DispatchResultWithPostInfo { let controller = ensure_signed(origin)?; - let actual_weight = Self::do_withdraw_unbonded(&controller, num_slashing_spans)?; + let actual_weight = Self::do_withdraw_unbonded(&controller, num_slashing_spans, None)?; Ok(Some(actual_weight).into()) } @@ -1315,9 +1311,7 @@ pub mod pallet { Error::::ControllerDeprecated ); - let _ = ledger - .set_payee(payee) - .defensive_proof("ledger was retrieved from storage, thus its bonded; qed.")?; + ledger.set_payee(payee)?; Ok(()) } diff --git a/substrate/frame/staking/src/slashing.rs b/substrate/frame/staking/src/slashing.rs index 709fd1441ec3..3345031a36ca 100644 --- a/substrate/frame/staking/src/slashing.rs +++ b/substrate/frame/staking/src/slashing.rs @@ -610,7 +610,13 @@ pub fn do_slash( let value = ledger.slash(value, T::Currency::minimum_balance(), slash_era); - if !value.is_zero() { + if value.is_zero() { + // nothing to do + return + } + + // Skip slashing for virtual nominators. The pallets managing them should handle the slashing. + if !Pallet::::is_virtual_nominator(stash) { let (imbalance, missing) = T::Currency::slash(stash, value); slashed_imbalance.subsume(imbalance); @@ -618,17 +624,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/primitives/staking/Cargo.toml b/substrate/primitives/staking/Cargo.toml index 6304551b8e60..21346fbaca53 100644 --- a/substrate/primitives/staking/Cargo.toml +++ b/substrate/primitives/staking/Cargo.toml @@ -23,6 +23,7 @@ impl-trait-for-tuples = "0.2.2" sp-core = { path = "../core", default-features = false } sp-runtime = { path = "../runtime", default-features = false } +sp-std = { path = "../std", default-features = false } [features] default = ["std"] @@ -32,5 +33,6 @@ std = [ "serde/std", "sp-core/std", "sp-runtime/std", + "sp-std/std", ] runtime-benchmarks = ["sp-runtime/runtime-benchmarks"] diff --git a/substrate/primitives/staking/src/delegation.rs b/substrate/primitives/staking/src/delegation.rs new file mode 100644 index 000000000000..e7cad230b270 --- /dev/null +++ b/substrate/primitives/staking/src/delegation.rs @@ -0,0 +1,76 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::StakingInterface; +use sp_runtime::{DispatchResult}; + +/// Trait that extends on [`StakingInterface`] to provide additional capability to delegate funds to +/// an account. +pub trait DelegatedStakeInterface: StakingInterface { + /// Effective balance of the `delegatee` account. + /// + /// This takes into account any pending slashes to `Delegatee`. + fn delegatee_balance(delegatee: &Self::AccountId) -> Self::Balance; + + /// Returns the total amount of funds delegated by a `delegator`. + fn delegator_balance(delegator: &Self::AccountId) -> Self::Balance; + + /// Delegate funds to `delegatee`. + /// + /// Only used for the initial delegation. Use [`Self::delegate_extra`] to add more delegation. + fn delegate( + delegator: &Self::AccountId, + delegatee: &Self::AccountId, + reward_account: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult; + + /// Add more delegation to the `delegatee`. + /// + /// If this is the first delegation, use [`Self::delegate`] instead. + fn delegate_extra( + delegator: &Self::AccountId, + delegatee: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult; + + /// Withdraw or revoke delegation to `delegatee`. + /// + /// If there are `delegatee` funds upto `amount` available to withdraw, then those funds would + /// be released to the `delegator` + fn withdraw_delegation( + delegator: &Self::AccountId, + delegatee: &Self::AccountId, + amount: Self::Balance, + ) -> DispatchResult; + + /// Returns true if there are pending slashes posted to the `delegatee` account. + /// + /// Slashes to `delegatee` account are not immediate and are applied lazily. Since `delegatee` + /// has an unbounded number of delegators, immediate slashing is not possible. + fn has_pending_slash(delegatee: &Self::AccountId) -> bool; + + /// Apply a pending slash to a `delegatee` by slashing `value` from `delegator`. + /// + /// If a reporter is provided, the reporter will receive a fraction of the slash as reward. + fn delegator_slash( + delegatee: &Self::AccountId, + delegator: &Self::AccountId, + value: Self::Balance, + maybe_reporter: Option, + ) -> sp_runtime::DispatchResult; +} diff --git a/substrate/primitives/staking/src/lib.rs b/substrate/primitives/staking/src/lib.rs index 11b7ef41b9a7..2300ab062b37 100644 --- a/substrate/primitives/staking/src/lib.rs +++ b/substrate/primitives/staking/src/lib.rs @@ -29,12 +29,13 @@ 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; pub mod currency_to_vote; +pub mod delegation; /// Simple index type with which we can count sessions. pub type SessionIndex = u32; @@ -172,6 +173,7 @@ pub trait StakingInterface { + MaxEncodedLen + FullCodec + TypeInfo + + Zero + Saturating; /// AccountId type used by the staking system. @@ -254,6 +256,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 +279,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 +295,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 +312,26 @@ pub trait StakingInterface { fn set_current_era(era: EraIndex); } +/// Set of low level apis to manipulate staking ledger. +/// +/// These apis bypass safety checks and should only be used if you know what you are doing. +pub trait StakingUnsafe: StakingInterface { + /// Release all funds bonded for stake without unbonding the ledger. + /// + /// Unsafe, only used for migration of `nominator` to `virtual_nominator`. + fn force_release(who: &Self::AccountId); + + /// Book-keep a new bond for `who` without applying any locks (hence virtual). + /// + /// It is important that 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; +} + /// 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 {