diff --git a/node/service/src/chain_spec/moonbase.rs b/node/service/src/chain_spec/moonbase.rs index c10fb9b42d..9f5ce551f8 100644 --- a/node/service/src/chain_spec/moonbase.rs +++ b/node/service/src/chain_spec/moonbase.rs @@ -226,7 +226,7 @@ pub fn testnet_genesis( tech_comittee_members: Vec, treasury_council_members: Vec, candidates: Vec<(AccountId, NimbusId, Balance)>, - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, endowed_accounts: Vec, crowdloan_fund_pot: Balance, para_id: ParaId, diff --git a/node/service/src/chain_spec/moonbeam.rs b/node/service/src/chain_spec/moonbeam.rs index ebcfea9017..7995dac859 100644 --- a/node/service/src/chain_spec/moonbeam.rs +++ b/node/service/src/chain_spec/moonbeam.rs @@ -220,7 +220,7 @@ pub fn testnet_genesis( tech_comittee_members: Vec, treasury_council_members: Vec, candidates: Vec<(AccountId, NimbusId, Balance)>, - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, endowed_accounts: Vec, crowdloan_fund_pot: Balance, para_id: ParaId, diff --git a/node/service/src/chain_spec/moonriver.rs b/node/service/src/chain_spec/moonriver.rs index ce4a3cb8a2..7a191a17c4 100644 --- a/node/service/src/chain_spec/moonriver.rs +++ b/node/service/src/chain_spec/moonriver.rs @@ -220,7 +220,7 @@ pub fn testnet_genesis( tech_comittee_members: Vec, treasury_council_members: Vec, candidates: Vec<(AccountId, NimbusId, Balance)>, - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, endowed_accounts: Vec, crowdloan_fund_pot: Balance, para_id: ParaId, diff --git a/pallets/parachain-staking/Cargo.toml b/pallets/parachain-staking/Cargo.toml index 27605bf890..10cf9c614b 100644 --- a/pallets/parachain-staking/Cargo.toml +++ b/pallets/parachain-staking/Cargo.toml @@ -23,9 +23,8 @@ substrate-fixed = { git = "https://github.com/encointer/substrate-fixed", defaul nimbus-primitives = { git = "https://github.com/purestake/nimbus", branch = "moonbeam-polkadot-v0.9.29", default-features = false } [dev-dependencies] -similar-asserts = "1.1.0" - pallet-balances = { git = "https://github.com/purestake/substrate", branch = "moonbeam-polkadot-v0.9.29" } +similar-asserts = "1.1.0" sp-core = { git = "https://github.com/purestake/substrate", branch = "moonbeam-polkadot-v0.9.29" } sp-io = { git = "https://github.com/purestake/substrate", branch = "moonbeam-polkadot-v0.9.29" } diff --git a/pallets/parachain-staking/src/auto_compound.rs b/pallets/parachain-staking/src/auto_compound.rs new file mode 100644 index 0000000000..653c96ff96 --- /dev/null +++ b/pallets/parachain-staking/src/auto_compound.rs @@ -0,0 +1,377 @@ +// Copyright 2019-2022 PureStake Inc. +// This file is part of Moonbeam. + +// Moonbeam 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. + +// Moonbeam 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 Moonbeam. If not, see . + +//! Auto-compounding functionality for staking rewards + +use crate::pallet::{ + AutoCompoundingDelegations as AutoCompoundingDelegationsStorage, BalanceOf, CandidateInfo, + Config, DelegatorState, Error, Event, Pallet, Total, +}; +use crate::types::{Bond, BondAdjust, Delegator}; +use frame_support::ensure; +use frame_support::traits::Get; +use frame_support::{dispatch::DispatchResultWithPostInfo, RuntimeDebug}; +use parity_scale_codec::{Decode, Encode}; +use scale_info::TypeInfo; +use sp_runtime::traits::Saturating; +use sp_runtime::Percent; +use sp_std::prelude::*; +use sp_std::vec::Vec; + +/// Represents the auto-compounding amount for a delegation. +#[derive(Clone, Eq, PartialEq, Encode, Decode, RuntimeDebug, TypeInfo, PartialOrd, Ord)] +pub struct AutoCompoundConfig { + pub delegator: AccountId, + pub value: Percent, +} + +/// Represents the auto-compounding [Delegations] for `T: Config` +#[derive(Clone, Eq, PartialEq, RuntimeDebug)] +pub struct AutoCompoundDelegations(Vec>); + +impl AutoCompoundDelegations +where + T: Config, +{ + /// Creates a new instance of [AutoCompoundingDelegations] from a vector of sorted_delegations. + /// This is used for testing purposes only. + #[cfg(test)] + pub fn new(sorted_delegations: Vec>) -> Self { + Self(sorted_delegations) + } + + /// Retrieves an instance of [AutoCompoundingDelegations] storage as [AutoCompoundDelegations]. + pub fn get_storage(candidate: &T::AccountId) -> Self { + Self(>::get(candidate)) + } + + /// Inserts the current state to [AutoCompoundingDelegations] storage. + pub fn set_storage(self, candidate: &T::AccountId) { + >::insert(candidate, self.0) + } + + /// Retrieves the auto-compounding value for a delegation. The `delegations_config` must be a + /// sorted vector for binary_search to work. + pub fn get_for_delegator(&self, delegator: &T::AccountId) -> Option { + match self.0.binary_search_by(|d| d.delegator.cmp(&delegator)) { + Ok(index) => Some(self.0[index].value), + Err(_) => None, + } + } + + /// Sets the auto-compounding value for a delegation. The `delegations_config` must be a sorted + /// vector for binary_search to work. + pub fn set_for_delegator(&mut self, delegator: T::AccountId, value: Percent) -> bool { + match self.0.binary_search_by(|d| d.delegator.cmp(&delegator)) { + Ok(index) => { + if self.0[index].value == value { + false + } else { + self.0[index].value = value; + true + } + } + Err(index) => { + self.0 + .insert(index, AutoCompoundConfig { delegator, value }); + true + } + } + } + + /// Removes the auto-compounding value for a delegation. + /// Returns `true` if the entry was removed, `false` otherwise. The `delegations_config` must be a + /// sorted vector for binary_search to work. + pub fn remove_for_delegator(&mut self, delegator: &T::AccountId) -> bool { + match self.0.binary_search_by(|d| d.delegator.cmp(&delegator)) { + Ok(index) => { + self.0.remove(index); + true + } + Err(_) => false, + } + } + + /// Returns the length of the inner vector. + pub fn len(&self) -> u32 { + self.0.len() as u32 + } + + /// Returns a reference to the inner vector. + #[cfg(test)] + pub fn inner(&self) -> &Vec> { + &self.0 + } + + /// Converts the [AutoCompoundDelegations] into the inner vector. + #[cfg(test)] + pub fn into_inner(self) -> Vec> { + self.0 + } + + // -- pallet functions -- + + /// Delegates and sets the auto-compounding config. The function skips inserting auto-compound + /// storage and validation, if the auto-compound value is 0%. + pub(crate) fn delegate_with_auto_compound( + candidate: T::AccountId, + delegator: T::AccountId, + amount: BalanceOf, + auto_compound: Percent, + candidate_delegation_count_hint: u32, + candidate_auto_compounding_delegation_count_hint: u32, + delegation_count_hint: u32, + ) -> DispatchResultWithPostInfo { + // check that caller can lock the amount before any changes to storage + ensure!( + >::get_delegator_stakable_free_balance(&delegator) >= amount, + Error::::InsufficientBalance + ); + + let mut delegator_state = if let Some(mut state) = >::get(&delegator) { + // delegation after first + ensure!( + amount >= T::MinDelegation::get(), + Error::::DelegationBelowMin + ); + ensure!( + delegation_count_hint >= state.delegations.0.len() as u32, + Error::::TooLowDelegationCountToDelegate + ); + ensure!( + (state.delegations.0.len() as u32) < T::MaxDelegationsPerDelegator::get(), + Error::::ExceedMaxDelegationsPerDelegator + ); + ensure!( + state.add_delegation(Bond { + owner: candidate.clone(), + amount + }), + Error::::AlreadyDelegatedCandidate + ); + state + } else { + // first delegation + ensure!( + amount >= T::MinDelegatorStk::get(), + Error::::DelegatorBondBelowMin + ); + ensure!( + !>::is_candidate(&delegator), + Error::::CandidateExists + ); + Delegator::new(delegator.clone(), candidate.clone(), amount) + }; + let mut candidate_state = + >::get(&candidate).ok_or(Error::::CandidateDNE)?; + ensure!( + candidate_delegation_count_hint >= candidate_state.delegation_count, + Error::::TooLowCandidateDelegationCountToDelegate + ); + + let auto_compounding_state = if !auto_compound.is_zero() { + let auto_compounding_state = Self::get_storage(&candidate); + ensure!( + auto_compounding_state.len() <= candidate_auto_compounding_delegation_count_hint, + >::TooLowCandidateAutoCompoundingDelegationCountToDelegate, + ); + Some(auto_compounding_state) + } else { + None + }; + + // add delegation to candidate + let (delegator_position, less_total_staked) = candidate_state.add_delegation::( + &candidate, + Bond { + owner: delegator.clone(), + amount, + }, + )?; + + // lock delegator amount + delegator_state.adjust_bond_lock::(BondAdjust::Increase(amount))?; + + // adjust total locked, + // only is_some if kicked the lowest bottom as a consequence of this new delegation + let net_total_increase = if let Some(less) = less_total_staked { + amount.saturating_sub(less) + } else { + amount + }; + let new_total_locked = >::get().saturating_add(net_total_increase); + + // maybe set auto-compound config, state is Some if the percent is non-zero + if let Some(mut state) = auto_compounding_state { + state.set_for_delegator(delegator.clone(), auto_compound.clone()); + state.set_storage(&candidate); + } + + >::put(new_total_locked); + >::insert(&candidate, candidate_state); + >::insert(&delegator, delegator_state); + >::deposit_event(Event::Delegation { + delegator: delegator, + locked_amount: amount, + candidate: candidate, + delegator_position: delegator_position, + auto_compound, + }); + + Ok(().into()) + } + + /// Sets the auto-compounding value for a delegation. The config is removed if value is zero. + pub(crate) fn set_auto_compound( + candidate: T::AccountId, + delegator: T::AccountId, + value: Percent, + candidate_auto_compounding_delegation_count_hint: u32, + delegation_count_hint: u32, + ) -> DispatchResultWithPostInfo { + let delegator_state = + >::get(&delegator).ok_or(>::DelegatorDNE)?; + ensure!( + delegator_state.delegations.0.len() <= delegation_count_hint as usize, + >::TooLowDelegationCountToAutoCompound, + ); + ensure!( + delegator_state + .delegations + .0 + .iter() + .any(|b| b.owner == candidate), + >::DelegationDNE, + ); + + let mut auto_compounding_state = Self::get_storage(&candidate); + ensure!( + auto_compounding_state.len() <= candidate_auto_compounding_delegation_count_hint, + >::TooLowCandidateAutoCompoundingDelegationCountToAutoCompound, + ); + let state_updated = if value.is_zero() { + auto_compounding_state.remove_for_delegator(&delegator) + } else { + auto_compounding_state.set_for_delegator(delegator.clone(), value) + }; + if state_updated { + auto_compounding_state.set_storage(&candidate); + } + + >::deposit_event(Event::AutoCompoundSet { + candidate, + delegator, + value, + }); + + Ok(().into()) + } + + /// Removes the auto-compounding value for a delegation. This should be called when the + /// delegation is revoked to cleanup storage. Storage is only written iff the entry existed. + pub(crate) fn remove_auto_compound(candidate: &T::AccountId, delegator: &T::AccountId) { + let mut auto_compounding_state = Self::get_storage(candidate); + if auto_compounding_state.remove_for_delegator(delegator) { + auto_compounding_state.set_storage(&candidate); + } + } + + /// Returns the value of auto-compound, if it exists for a given delegation, zero otherwise. + pub(crate) fn auto_compound(candidate: &T::AccountId, delegator: &T::AccountId) -> Percent { + let delegations_config = Self::get_storage(candidate); + delegations_config + .get_for_delegator(&delegator) + .unwrap_or_else(|| Percent::zero()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mock::Test; + + #[test] + fn test_set_for_delegator_inserts_config_and_returns_true_if_entry_missing() { + let mut delegations_config = AutoCompoundDelegations::::new(vec![]); + assert_eq!( + true, + delegations_config.set_for_delegator(1, Percent::from_percent(50)) + ); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(50), + }], + delegations_config.into_inner(), + ); + } + + #[test] + fn test_set_for_delegator_updates_config_and_returns_true_if_entry_changed() { + let mut delegations_config = + AutoCompoundDelegations::::new(vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(10), + }]); + assert_eq!( + true, + delegations_config.set_for_delegator(1, Percent::from_percent(50)) + ); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(50), + }], + delegations_config.into_inner(), + ); + } + + #[test] + fn test_set_for_delegator_updates_config_and_returns_false_if_entry_unchanged() { + let mut delegations_config = + AutoCompoundDelegations::::new(vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(10), + }]); + assert_eq!( + false, + delegations_config.set_for_delegator(1, Percent::from_percent(10)) + ); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(10), + }], + delegations_config.into_inner(), + ); + } + + #[test] + fn test_remove_for_delegator_returns_false_if_entry_was_missing() { + let mut delegations_config = AutoCompoundDelegations::::new(vec![]); + assert_eq!(false, delegations_config.remove_for_delegator(&1),); + } + + #[test] + fn test_remove_delegation_config_returns_true_if_entry_existed() { + let mut delegations_config = + AutoCompoundDelegations::::new(vec![AutoCompoundConfig { + delegator: 1, + value: Percent::from_percent(10), + }]); + assert_eq!(true, delegations_config.remove_for_delegator(&1)); + } +} diff --git a/pallets/parachain-staking/src/benchmarks.rs b/pallets/parachain-staking/src/benchmarks.rs index 814df77ff3..ee698df9a2 100644 --- a/pallets/parachain-staking/src/benchmarks.rs +++ b/pallets/parachain-staking/src/benchmarks.rs @@ -128,6 +128,20 @@ fn roll_to_and_author(round_delay: u32, author: T::AccountId) { } const USER_SEED: u32 = 999666; +struct Seed { + pub inner: u32, +} +impl Seed { + fn new() -> Self { + Seed { inner: USER_SEED } + } + + pub fn take(&mut self) -> u32 { + let v = self.inner; + self.inner += 1; + v + } +} benchmarks! { // MONETARY ORIGIN DISPATCHABLES @@ -985,7 +999,7 @@ benchmarks! { // must come after 'let foo in 0..` statements for macro use crate::{ - DelayedPayout, DelayedPayouts, AtStake, CollatorSnapshot, Bond, Points, + DelayedPayout, DelayedPayouts, AtStake, CollatorSnapshot, BondWithAutoCompound, Points, AwardedPts, }; @@ -1031,11 +1045,12 @@ benchmarks! { collator_commission: Perbill::from_rational(1u32, 100u32), }); - let mut delegations: Vec>> = Vec::new(); + let mut delegations: Vec>> = Vec::new(); for delegator in &delegators { - delegations.push(Bond { + delegations.push(BondWithAutoCompound { owner: delegator.clone(), amount: 100u32.into(), + auto_compound: Percent::zero(), }); } @@ -1091,6 +1106,189 @@ benchmarks! { // Round transitions assert_eq!(start + 1u32.into(), end); } + + set_auto_compound { + // x controls number of distinct auto-compounding delegations the prime collator will have + // y controls number of distinct delegations the prime delegator will have + let x in 0..<::MaxTopDelegationsPerCandidate as Get>::get(); + let y in 0..<::MaxDelegationsPerDelegator as Get>::get(); + + use crate::auto_compound::AutoCompoundDelegations; + + let min_candidate_stake = min_candidate_stk::(); + let min_delegator_stake = min_delegator_stk::(); + let mut seed = Seed::new(); + + // initialize the prime collator + let prime_candidate = create_funded_collator::( + "collator", + seed.take(), + min_candidate_stake, + true, + 1, + )?; + + // initialize the prime delegator + let prime_delegator = create_funded_delegator::( + "delegator", + seed.take(), + min_delegator_stake * (x+1).into(), + prime_candidate.clone(), + true, + 0, + )?; + + // have x-1 distinct auto-compounding delegators delegate to prime collator + // we directly set the storage, since benchmarks don't work when the same extrinsic is + // called from within the benchmark. + let mut auto_compounding_state = >::get_storage(&prime_candidate); + for i in 1..x { + let delegator = create_funded_delegator::( + "delegator", + seed.take(), + min_delegator_stake, + prime_candidate.clone(), + true, + i, + )?; + auto_compounding_state.set_for_delegator( + delegator, + Percent::from_percent(100), + ); + } + auto_compounding_state.set_storage(&prime_candidate); + + // delegate to y-1 distinct collators from the prime delegator + for i in 1..y { + let collator = create_funded_collator::( + "collator", + seed.take(), + min_candidate_stake, + true, + i+1, + )?; + Pallet::::delegate( + RawOrigin::Signed(prime_delegator.clone()).into(), + collator, + min_delegator_stake, + 0, + i, + )?; + } + }: { + Pallet::::set_auto_compound( + RawOrigin::Signed(prime_delegator.clone()).into(), + prime_candidate.clone(), + Percent::from_percent(50), + x, + y+1, + )?; + } + verify { + let actual_auto_compound = >::get_storage(&prime_candidate) + .get_for_delegator(&prime_delegator); + let expected_auto_compound = Some(Percent::from_percent(50)); + assert_eq!( + expected_auto_compound, + actual_auto_compound, + "delegation must have an auto-compound entry", + ); + } + + delegate_with_auto_compound { + // x controls number of distinct delegations the prime collator will have + // y controls number of distinct auto-compounding delegations the prime collator will have + // z controls number of distinct delegations the prime delegator will have + let x in 0..(<::MaxTopDelegationsPerCandidate as Get>::get() + + <::MaxBottomDelegationsPerCandidate as Get>::get()); + let y in 0..<::MaxTopDelegationsPerCandidate as Get>::get() + + <::MaxBottomDelegationsPerCandidate as Get>::get(); + let z in 0..<::MaxDelegationsPerDelegator as Get>::get(); + + use crate::auto_compound::AutoCompoundDelegations; + + let min_candidate_stake = min_candidate_stk::(); + let min_delegator_stake = min_delegator_stk::(); + let mut seed = Seed::new(); + + // initialize the prime collator + let prime_candidate = create_funded_collator::( + "collator", + seed.take(), + min_candidate_stake, + true, + 1, + )?; + + // initialize the future delegator + let (prime_delegator, _) = create_funded_user::( + "delegator", + seed.take(), + min_delegator_stake * (x+1).into(), + ); + + // have x-1 distinct delegators delegate to prime collator, of which y are auto-compounding. + // we can directly set the storage here. + let auto_compound_z = x * y / 100; + for i in 1..x { + let delegator = create_funded_delegator::( + "delegator", + seed.take(), + min_delegator_stake, + prime_candidate.clone(), + true, + i, + )?; + if i <= y { + Pallet::::set_auto_compound( + RawOrigin::Signed(delegator.clone()).into(), + prime_candidate.clone(), + Percent::from_percent(100), + i+1, + i, + )?; + } + } + + // delegate to z-1 distinct collators from the prime delegator + for i in 1..z { + let collator = create_funded_collator::( + "collator", + seed.take(), + min_candidate_stake, + true, + i+1, + )?; + Pallet::::delegate( + RawOrigin::Signed(prime_delegator.clone()).into(), + collator, + min_delegator_stake, + 0, + i, + )?; + } + }: { + Pallet::::delegate_with_auto_compound( + RawOrigin::Signed(prime_delegator.clone()).into(), + prime_candidate.clone(), + min_delegator_stake, + Percent::from_percent(50), + x, + y, + z, + )?; + } + verify { + assert!(Pallet::::is_delegator(&prime_delegator)); + let actual_auto_compound = >::get_storage(&prime_candidate) + .get_for_delegator(&prime_delegator); + let expected_auto_compound = Some(Percent::from_percent(50)); + assert_eq!( + expected_auto_compound, + actual_auto_compound, + "delegation must have an auto-compound entry", + ); + } } #[cfg(test)] diff --git a/pallets/parachain-staking/src/delegation_requests.rs b/pallets/parachain-staking/src/delegation_requests.rs index 6a5985f5d8..6ca5e64112 100644 --- a/pallets/parachain-staking/src/delegation_requests.rs +++ b/pallets/parachain-staking/src/delegation_requests.rs @@ -20,7 +20,7 @@ use crate::pallet::{ BalanceOf, CandidateInfo, Config, DelegationScheduledRequests, DelegatorState, Error, Event, Pallet, Round, RoundIndex, Total, }; -use crate::{Delegator, DelegatorStatus}; +use crate::{auto_compound::AutoCompoundDelegations, Delegator, DelegatorStatus}; use frame_support::ensure; use frame_support::traits::Get; use frame_support::{dispatch::DispatchResultWithPostInfo, RuntimeDebug}; @@ -248,6 +248,9 @@ impl Pallet { // remove delegation from delegator state state.rm_delegation::(&collator); + // remove delegation from auto-compounding info + >::remove_auto_compound(&collator, &delegator); + // remove delegation from collator state delegations Self::delegator_leaves_candidate(collator.clone(), delegator.clone(), amount)?; Self::deposit_event(Event::DelegationRevoked { @@ -479,6 +482,7 @@ impl Pallet { } Self::delegation_remove_request_with_state(&bond.owner, &delegator, &mut state); + >::remove_auto_compound(&bond.owner, &delegator); } >::remove(&delegator); Self::deposit_event(Event::DelegatorLeft { @@ -525,7 +529,10 @@ impl Pallet { // remove the scheduled request, since it is fulfilled scheduled_requests.remove(request_idx).action.amount(); - updated_scheduled_requests.push((collator, scheduled_requests)); + updated_scheduled_requests.push((collator.clone(), scheduled_requests)); + + // remove the auto-compounding entry for the delegation + >::remove_auto_compound(&collator, &delegator); } // set state.total so that state.adjust_bond_lock will remove lock diff --git a/pallets/parachain-staking/src/lib.rs b/pallets/parachain-staking/src/lib.rs index e7661da085..e90c7b495c 100644 --- a/pallets/parachain-staking/src/lib.rs +++ b/pallets/parachain-staking/src/lib.rs @@ -48,6 +48,7 @@ #![cfg_attr(not(feature = "std"), no_std)] +mod auto_compound; mod delegation_requests; pub mod inflation; pub mod migrations; @@ -67,6 +68,7 @@ use frame_support::pallet; pub use inflation::{InflationInfo, Range}; use weights::WeightInfo; +pub use auto_compound::{AutoCompoundConfig, AutoCompoundDelegations}; pub use delegation_requests::{CancelledScheduledRequest, DelegationAction, ScheduledRequest}; pub use pallet::*; pub use traits::*; @@ -79,13 +81,13 @@ pub mod pallet { CancelledScheduledRequest, DelegationAction, ScheduledRequest, }; use crate::{set::OrderedSet, traits::*, types::*, InflationInfo, Range, WeightInfo}; + use crate::{AutoCompoundConfig, AutoCompoundDelegations}; use frame_support::pallet_prelude::*; use frame_support::traits::{ tokens::WithdrawReasons, Currency, Get, Imbalance, LockIdentifier, LockableCurrency, ReservableCurrency, }; use frame_system::pallet_prelude::*; - use parity_scale_codec::Decode; use sp_runtime::{ traits::{Saturating, Zero}, Perbill, Percent, @@ -217,6 +219,9 @@ pub mod pallet { PendingDelegationRequestNotDueYet, CannotDelegateLessThanOrEqualToLowestBottomWhenFull, PendingDelegationRevoke, + TooLowDelegationCountToAutoCompound, + TooLowCandidateAutoCompoundingDelegationCountToAutoCompound, + TooLowCandidateAutoCompoundingDelegationCountToDelegate, } #[pallet::event] @@ -348,6 +353,7 @@ pub mod pallet { locked_amount: BalanceOf, candidate: T::AccountId, delegator_position: DelegatorAdded>, + auto_compound: Percent, }, /// Delegation from candidate state has been remove. DelegatorLeftCandidate { @@ -402,6 +408,18 @@ pub mod pallet { new_per_round_inflation_ideal: Perbill, new_per_round_inflation_max: Perbill, }, + /// Auto-compounding reward percent was set for a delegation. + AutoCompoundSet { + candidate: T::AccountId, + delegator: T::AccountId, + value: Percent, + }, + /// Compounded a portion of rewards towards the delegation. + Compounded { + candidate: T::AccountId, + delegator: T::AccountId, + amount: BalanceOf, + }, } #[pallet::hooks] @@ -502,6 +520,17 @@ pub mod pallet { ValueQuery, >; + /// Stores auto-compounding configuration per collator. + #[pallet::storage] + #[pallet::getter(fn auto_compounding_delegations)] + pub(crate) type AutoCompoundingDelegations = StorageMap< + _, + Blake2_128Concat, + T::AccountId, + Vec>, + ValueQuery, + >; + #[pallet::storage] #[pallet::getter(fn top_delegations)] /// Top delegations for collator candidate @@ -553,6 +582,11 @@ pub mod pallet { ValueQuery, >; + /// Migration storage holding value for collators already migrated to the new snapshot variant + #[pallet::storage] + #[pallet::getter(fn migrated_at_stake)] + pub type MigratedAtStake = StorageValue<_, RoundIndex, OptionQuery>; + #[pallet::storage] #[pallet::getter(fn delayed_payouts)] /// Delayed payouts @@ -592,8 +626,8 @@ pub mod pallet { /// Initialize balance and register all as collators: `(collator AccountId, balance Amount)` pub candidates: Vec<(T::AccountId, BalanceOf)>, /// Initialize balance and make delegations: - /// `(delegator AccountId, collator AccountId, delegation Amount)` - pub delegations: Vec<(T::AccountId, T::AccountId, BalanceOf)>, + /// `(delegator AccountId, collator AccountId, delegation Amount, auto-compounding Percent)` + pub delegations: Vec<(T::AccountId, T::AccountId, BalanceOf, Percent)>, /// Inflation configuration pub inflation_config: InflationInfo>, /// Default fixed percent a collator takes off the top of due rewards @@ -641,10 +675,13 @@ pub mod pallet { candidate_count = candidate_count.saturating_add(1u32); } } + let mut col_delegator_count: BTreeMap = BTreeMap::new(); + let mut col_auto_compound_delegator_count: BTreeMap = + BTreeMap::new(); let mut del_delegation_count: BTreeMap = BTreeMap::new(); // Initialize the delegations - for &(ref delegator, ref target, balance) in &self.delegations { + for &(ref delegator, ref target, balance, auto_compound) in &self.delegations { assert!( >::get_delegator_stakable_free_balance(delegator) >= balance, "Account does not have enough balance to place delegation." @@ -659,11 +696,17 @@ pub mod pallet { } else { 0u32 }; - if let Err(error) = >::delegate( + let cd_auto_compound_count = col_auto_compound_delegator_count + .get(target) + .cloned() + .unwrap_or_default(); + if let Err(error) = >::delegate_with_auto_compound( T::Origin::from(Some(delegator.clone()).into()), target.clone(), balance, + auto_compound, cd_count, + cd_auto_compound_count, dd_count, ) { log::warn!("Delegate failed in genesis with error {:?}", error); @@ -678,6 +721,12 @@ pub mod pallet { } else { del_delegation_count.insert(delegator.clone(), 1u32); }; + if !auto_compound.is_zero() { + col_auto_compound_delegator_count + .entry(target.clone()) + .and_modify(|x| *x = x.saturating_add(1)) + .or_insert(1); + } } } // Set collator commission to default config @@ -967,6 +1016,7 @@ pub mod pallet { &bond.owner, &mut delegator, ); + >::remove_auto_compound(&candidate, &bond.owner); if remaining.is_zero() { // we do not remove the scheduled delegation requests from other collators @@ -1004,6 +1054,7 @@ pub mod pallet { T::Currency::remove_lock(COLLATOR_LOCK_ID, &candidate); >::remove(&candidate); >::remove(&candidate); + >::remove(&candidate); >::remove(&candidate); >::remove(&candidate); let new_total_staked = >::get().saturating_sub(total_backing); @@ -1156,74 +1207,46 @@ pub mod pallet { delegation_count: u32, ) -> DispatchResultWithPostInfo { let delegator = ensure_signed(origin)?; - // check that caller can reserve the amount before any changes to storage - ensure!( - Self::get_delegator_stakable_free_balance(&delegator) >= amount, - Error::::InsufficientBalance - ); - let mut delegator_state = if let Some(mut state) = >::get(&delegator) - { - // delegation after first - ensure!( - amount >= T::MinDelegation::get(), - Error::::DelegationBelowMin - ); - ensure!( - delegation_count >= state.delegations.0.len() as u32, - Error::::TooLowDelegationCountToDelegate - ); - ensure!( - (state.delegations.0.len() as u32) < T::MaxDelegationsPerDelegator::get(), - Error::::ExceedMaxDelegationsPerDelegator - ); - ensure!( - state.add_delegation(Bond { - owner: candidate.clone(), - amount - }), - Error::::AlreadyDelegatedCandidate - ); - state - } else { - // first delegation - ensure!( - amount >= T::MinDelegatorStk::get(), - Error::::DelegatorBondBelowMin - ); - ensure!(!Self::is_candidate(&delegator), Error::::CandidateExists); - Delegator::new(delegator.clone(), candidate.clone(), amount) - }; - let mut state = >::get(&candidate).ok_or(Error::::CandidateDNE)?; - ensure!( - candidate_delegation_count >= state.delegation_count, - Error::::TooLowCandidateDelegationCountToDelegate - ); - let (delegator_position, less_total_staked) = state.add_delegation::( - &candidate, - Bond { - owner: delegator.clone(), - amount, - }, - )?; - // TODO: causes redundant free_balance check - delegator_state.adjust_bond_lock::(BondAdjust::Increase(amount))?; - // only is_some if kicked the lowest bottom as a consequence of this new delegation - let net_total_increase = if let Some(less) = less_total_staked { - amount.saturating_sub(less) - } else { - amount - }; - let new_total_locked = >::get().saturating_add(net_total_increase); - >::put(new_total_locked); - >::insert(&candidate, state); - >::insert(&delegator, delegator_state); - Self::deposit_event(Event::Delegation { - delegator: delegator, - locked_amount: amount, - candidate: candidate, - delegator_position: delegator_position, - }); - Ok(().into()) + >::delegate_with_auto_compound( + candidate, + delegator, + amount, + Percent::zero(), + candidate_delegation_count, + 0, + delegation_count, + ) + } + + /// If caller is not a delegator and not a collator, then join the set of delegators + /// If caller is a delegator, then makes delegation to change their delegation state + /// Sets the auto-compound config for the delegation + #[pallet::weight( + ::WeightInfo::delegate_with_auto_compound( + *candidate_delegation_count, + *candidate_auto_compounding_delegation_count, + *delegation_count, + ) + )] + pub fn delegate_with_auto_compound( + origin: OriginFor, + candidate: T::AccountId, + amount: BalanceOf, + auto_compound: Percent, + candidate_delegation_count: u32, + candidate_auto_compounding_delegation_count: u32, + delegation_count: u32, + ) -> DispatchResultWithPostInfo { + let delegator = ensure_signed(origin)?; + >::delegate_with_auto_compound( + candidate, + delegator, + amount, + auto_compound, + candidate_delegation_count, + candidate_auto_compounding_delegation_count, + delegation_count, + ) } /// DEPRECATED use batch util with schedule_revoke_delegation for all delegations @@ -1276,12 +1299,18 @@ pub mod pallet { more: BalanceOf, ) -> DispatchResultWithPostInfo { let delegator = ensure_signed(origin)?; - ensure!( - !Self::delegation_request_revoke_exists(&candidate, &delegator), - Error::::PendingDelegationRevoke - ); - let mut state = >::get(&delegator).ok_or(Error::::DelegatorDNE)?; - state.increase_delegation::(candidate.clone(), more)?; + let in_top = Self::delegation_bond_more_without_event( + delegator.clone(), + candidate.clone(), + more.clone(), + )?; + Pallet::::deposit_event(Event::DelegationIncreased { + delegator, + candidate, + amount: more, + in_top, + }); + Ok(().into()) } @@ -1317,6 +1346,28 @@ pub mod pallet { Self::delegation_cancel_request(candidate, delegator) } + /// Sets the auto-compounding reward percentage for a delegation. + #[pallet::weight(::WeightInfo::set_auto_compound( + *candidate_auto_compounding_delegation_count_hint, + *delegation_count_hint, + ))] + pub fn set_auto_compound( + origin: OriginFor, + candidate: T::AccountId, + value: Percent, + candidate_auto_compounding_delegation_count_hint: u32, + delegation_count_hint: u32, + ) -> DispatchResultWithPostInfo { + let delegator = ensure_signed(origin)?; + >::set_auto_compound( + candidate, + delegator, + value, + candidate_auto_compounding_delegation_count_hint, + delegation_count_hint, + ) + } + /// Hotfix to remove existing empty entries for candidates that have left. #[pallet::weight( T::DbWeight::get().reads_writes(2 * candidates.len() as u64, candidates.len() as u64) @@ -1372,6 +1423,13 @@ pub mod pallet { } balance } + /// Returns a delegations auto-compound value. + pub fn delegation_auto_compound( + candidate: &T::AccountId, + delegator: &T::AccountId, + ) -> Percent { + >::auto_compound(candidate, delegator) + } /// Caller must ensure candidate is active before calling pub(crate) fn update_active(candidate: T::AccountId, total: BalanceOf) { let mut candidates = >::get(); @@ -1502,15 +1560,6 @@ pub mod pallet { return (None, Weight::zero()); } - let mint = |amt: BalanceOf, to: T::AccountId| { - if let Ok(amount_transferred) = T::Currency::deposit_into_existing(&to, amt) { - Self::deposit_event(Event::Rewarded { - account: to.clone(), - rewards: amount_transferred.peek(), - }); - } - }; - let collator_fee = payout_info.collator_commission; let collator_issuance = collator_fee * payout_info.round_issuance; @@ -1522,11 +1571,42 @@ pub mod pallet { let total_paid = pct_due * payout_info.total_staking_reward; let mut amt_due = total_paid; // Take the snapshot of block author and delegations - let state = >::take(paid_for_round, &collator); + + // Decode [CollatorSnapshot] depending upon when the storage was migrated + let is_at_stake_migrated = >::get() + .map_or(false, |migrated_at_round| { + paid_for_round >= migrated_at_round + }); + #[allow(deprecated)] + let state = if is_at_stake_migrated { + let at_stake: CollatorSnapshot> = + >::take(paid_for_round, &collator); + at_stake + } else { + // storage still not migrated, decode as deprecated CollatorSnapshot. + let key = >::hashed_key_for(paid_for_round, &collator); + let at_stake: deprecated::CollatorSnapshot> = + frame_support::storage::unhashed::get(&key).unwrap_or_default(); + + CollatorSnapshot { + bond: at_stake.bond, + delegations: at_stake + .delegations + .into_iter() + .map(|d| BondWithAutoCompound { + owner: d.owner, + amount: d.amount, + auto_compound: Percent::zero(), + }) + .collect(), + total: at_stake.total, + } + }; + let num_delegators = state.delegations.len(); if state.delegations.is_empty() { // solo collator with no delegators - mint(amt_due, collator.clone()); + Self::mint(amt_due, collator.clone()); extra_weight = extra_weight.saturating_add(T::OnCollatorPayout::on_collator_payout( paid_for_round, @@ -1539,19 +1619,30 @@ pub mod pallet { let commission = pct_due * collator_issuance; amt_due = amt_due.saturating_sub(commission); let collator_reward = (collator_pct * amt_due).saturating_add(commission); - mint(collator_reward, collator.clone()); + Self::mint(collator_reward, collator.clone()); extra_weight = extra_weight.saturating_add(T::OnCollatorPayout::on_collator_payout( paid_for_round, collator.clone(), collator_reward, )); + // pay delegators due portion - for Bond { owner, amount } in state.delegations { + for BondWithAutoCompound { + owner, + amount, + auto_compound, + } in state.delegations + { let percent = Perbill::from_rational(amount, state.total); let due = percent * amt_due; if !due.is_zero() { - mint(due, owner.clone()); + Self::mint_and_compound( + due, + auto_compound.clone(), + collator.clone(), + owner.clone(), + ); } } } @@ -1635,11 +1726,32 @@ pub mod pallet { } = Self::get_rewardable_delegators(&account); let total_counted = state.total_counted.saturating_sub(uncounted_stake); + let auto_compounding_delegations = >::get(&account) + .into_iter() + .map(|x| (x.delegator, x.value)) + .collect::>(); + let rewardable_delegations = rewardable_delegations + .into_iter() + .map(|d| BondWithAutoCompound { + owner: d.owner.clone(), + amount: d.amount, + auto_compound: auto_compounding_delegations + .get(&d.owner) + .cloned() + .unwrap_or_else(|| Percent::zero()), + }) + .collect(); + let snapshot = CollatorSnapshot { bond: state.bond, delegations: rewardable_delegations, total: total_counted, }; + >::mutate(|v| { + if v.is_none() { + *v = Some(now); + } + }); >::insert(now, account, snapshot); Self::deposit_event(Event::CollatorChosen { round: now, @@ -1702,6 +1814,79 @@ pub mod pallet { rewardable_delegations, } } + + /// This function exists as a helper to delegator_bond_more & auto_compound functionality. + /// Any changes to this function must align with both user-initiated bond increases and + /// auto-compounding bond increases. + /// Any feature-specific preconditions should be validated before this function is invoked. + /// Any feature-specific events must be emitted after this function is invoked. + pub fn delegation_bond_more_without_event( + delegator: T::AccountId, + candidate: T::AccountId, + more: BalanceOf, + ) -> Result { + ensure!( + !Self::delegation_request_revoke_exists(&candidate, &delegator), + Error::::PendingDelegationRevoke + ); + let mut state = >::get(&delegator).ok_or(Error::::DelegatorDNE)?; + state.increase_delegation::(candidate.clone(), more) + } + + /// Mint a specified reward amount to the beneficiary account. Emits the [Rewarded] event. + fn mint(amt: BalanceOf, to: T::AccountId) { + if let Ok(amount_transferred) = T::Currency::deposit_into_existing(&to, amt) { + Self::deposit_event(Event::Rewarded { + account: to.clone(), + rewards: amount_transferred.peek(), + }); + } + } + + /// Mint and compound delegation rewards. The function mints the amount towards the + /// delegator and tries to compound a specified percent of it back towards the delegation. + /// If a scheduled delegation revoke exists, then the amount is only minted, and nothing is + /// compounded. Emits the [Compounded] event. + fn mint_and_compound( + amt: BalanceOf, + compound_percent: Percent, + candidate: T::AccountId, + delegator: T::AccountId, + ) { + if let Ok(amount_transferred) = + T::Currency::deposit_into_existing(&delegator, amt.clone()) + { + Self::deposit_event(Event::Rewarded { + account: delegator.clone(), + rewards: amount_transferred.peek(), + }); + + let compound_amount = compound_percent.mul_ceil(amount_transferred.peek()); + if compound_amount.is_zero() { + return; + } + + if let Err(err) = Self::delegation_bond_more_without_event( + delegator.clone(), + candidate.clone(), + compound_amount.clone(), + ) { + log::error!( + "Error compounding staking reward towards candidate '{:?}' for delegator '{:?}': {:?}", + candidate, + delegator, + err + ); + return; + }; + + Pallet::::deposit_event(Event::Compounded { + delegator, + candidate, + amount: compound_amount.clone(), + }); + }; + } } /// Add reward points to block authors: diff --git a/pallets/parachain-staking/src/mock.rs b/pallets/parachain-staking/src/mock.rs index b603563a94..bfcba33440 100644 --- a/pallets/parachain-staking/src/mock.rs +++ b/pallets/parachain-staking/src/mock.rs @@ -149,8 +149,8 @@ pub(crate) struct ExtBuilder { balances: Vec<(AccountId, Balance)>, // [collator, amount] collators: Vec<(AccountId, Balance)>, - // [delegator, collator, delegation_amount] - delegations: Vec<(AccountId, AccountId, Balance)>, + // [delegator, collator, delegation_amount, auto_compound_percent] + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, // inflation config inflation: InflationInfo, } @@ -198,6 +198,17 @@ impl ExtBuilder { pub(crate) fn with_delegations( mut self, delegations: Vec<(AccountId, AccountId, Balance)>, + ) -> Self { + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, Percent::zero())) + .collect(); + self + } + + pub(crate) fn with_auto_compounding_delegations( + mut self, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, ) -> Self { self.delegations = delegations; self diff --git a/pallets/parachain-staking/src/tests.rs b/pallets/parachain-staking/src/tests.rs index 24c8976f47..88ebe8ce21 100644 --- a/pallets/parachain-staking/src/tests.rs +++ b/pallets/parachain-staking/src/tests.rs @@ -21,6 +21,7 @@ //! 2. Monetary Governance //! 3. Public (Collator, Nominator) //! 4. Miscellaneous Property-Based Tests +use crate::auto_compound::{AutoCompoundConfig, AutoCompoundDelegations}; use crate::delegation_requests::{CancelledScheduledRequest, DelegationAction, ScheduledRequest}; use crate::mock::{ roll_one_block, roll_to, roll_to_round_begin, roll_to_round_end, set_author, Balances, @@ -30,8 +31,8 @@ use crate::{ assert_eq_events, assert_eq_last_events, assert_event_emitted, assert_last_event, assert_tail_eq, set::OrderedSet, AtStake, Bond, BottomDelegations, CandidateInfo, CandidateMetadata, CandidatePool, CapacityStatus, CollatorStatus, DelegationScheduledRequests, - Delegations, DelegatorAdded, DelegatorState, DelegatorStatus, Error, Event, Range, - TopDelegations, DELEGATOR_LOCK_ID, + Delegations, DelegatorAdded, DelegatorState, DelegatorStatus, Error, Event, MigratedAtStake, + Range, TopDelegations, DELEGATOR_LOCK_ID, }; use frame_support::{assert_noop, assert_ok}; use sp_runtime::{traits::Zero, DispatchError, ModuleError, Perbill, Percent}; @@ -1745,6 +1746,7 @@ fn delegate_event_emits_correctly() { locked_amount: 10, candidate: 1, delegator_position: DelegatorAdded::AddedToTop { new_total: 40 }, + auto_compound: Percent::zero(), })); }); } @@ -4480,6 +4482,7 @@ fn parachain_bond_inflation_reserve_matches_config() { locked_amount: 10, candidate: 1, delegator_position: DelegatorAdded::AddedToTop { new_total: 50 }, + auto_compound: Percent::zero(), }, Event::ReservedForParachainBond { account: 11, @@ -4707,12 +4710,14 @@ fn paid_collator_commission_matches_config() { locked_amount: 10, candidate: 4, delegator_position: DelegatorAdded::AddedToTop { new_total: 30 }, + auto_compound: Percent::zero(), }, Event::Delegation { delegator: 6, locked_amount: 10, candidate: 4, delegator_position: DelegatorAdded::AddedToTop { new_total: 40 }, + auto_compound: Percent::zero(), }, Event::CollatorChosen { round: 3, @@ -5535,18 +5540,21 @@ fn multiple_delegations() { locked_amount: 10, candidate: 2, delegator_position: DelegatorAdded::AddedToTop { new_total: 50 }, + auto_compound: Percent::zero(), }, Event::Delegation { delegator: 6, locked_amount: 10, candidate: 3, delegator_position: DelegatorAdded::AddedToTop { new_total: 30 }, + auto_compound: Percent::zero(), }, Event::Delegation { delegator: 6, locked_amount: 10, candidate: 4, delegator_position: DelegatorAdded::AddedToTop { new_total: 30 }, + auto_compound: Percent::zero(), }, Event::CollatorChosen { round: 3, @@ -5660,12 +5668,14 @@ fn multiple_delegations() { locked_amount: 80, candidate: 2, delegator_position: DelegatorAdded::AddedToTop { new_total: 130 }, + auto_compound: Percent::zero(), }, Event::Delegation { delegator: 10, locked_amount: 10, candidate: 2, delegator_position: DelegatorAdded::AddedToBottom, + auto_compound: Percent::zero(), }, Event::CollatorChosen { round: 6, @@ -6286,6 +6296,7 @@ fn payouts_follow_delegation_changes() { locked_amount: 10, candidate: 1, delegator_position: DelegatorAdded::AddedToTop { new_total: 50 }, + auto_compound: Percent::zero(), }, Event::CollatorChosen { round: 10, @@ -6692,6 +6703,7 @@ fn delegation_events_convey_correct_position() { locked_amount: 15, candidate: 1, delegator_position: DelegatorAdded::AddedToTop { new_total: 74 }, + auto_compound: Percent::zero(), }); let collator1_state = ParachainStaking::candidate_info(1).unwrap(); // 12 + 13 + 14 + 15 + 20 = 70 (top 4 + self bond) @@ -6703,6 +6715,7 @@ fn delegation_events_convey_correct_position() { locked_amount: 10, candidate: 1, delegator_position: DelegatorAdded::AddedToBottom, + auto_compound: Percent::zero(), }); let collator1_state = ParachainStaking::candidate_info(1).unwrap(); // 12 + 13 + 14 + 15 + 20 = 70 (top 4 + self bond) @@ -8506,3 +8519,1085 @@ fn test_delegator_with_deprecated_status_leaving_cannot_execute_leave_delegators ); }); } + +#[test] +fn test_set_auto_compound_fails_if_invalid_delegation_hint() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + let candidate_auto_compounding_delegation_count_hint = 0; + let delegation_hint = 0; // is however, 1 + + assert_noop!( + ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + candidate_auto_compounding_delegation_count_hint, + delegation_hint, + ), + >::TooLowDelegationCountToAutoCompound, + ); + }); +} + +#[test] +fn test_set_auto_compound_fails_if_invalid_candidate_auto_compounding_hint() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + >::new(vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(10), + }]) + .set_storage(&1); + let candidate_auto_compounding_delegation_count_hint = 0; // is however, 1 + let delegation_hint = 1; + + assert_noop!( + ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + candidate_auto_compounding_delegation_count_hint, + delegation_hint, + ), + >::TooLowCandidateAutoCompoundingDelegationCountToAutoCompound, + ); + }); +} + +#[test] +fn test_set_auto_compound_inserts_if_not_exists() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 1, + )); + assert_event_emitted!(Event::AutoCompoundSet { + candidate: 1, + delegator: 2, + value: Percent::from_percent(50), + }); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(50), + }], + ParachainStaking::auto_compounding_delegations(&1), + ); + }); +} + +#[test] +fn test_set_auto_compound_updates_if_existing() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + >::new(vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(10), + }]) + .set_storage(&1); + + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 1, + 1, + )); + assert_event_emitted!(Event::AutoCompoundSet { + candidate: 1, + delegator: 2, + value: Percent::from_percent(50), + }); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(50), + }], + ParachainStaking::auto_compounding_delegations(&1), + ); + }); +} + +#[test] +fn test_set_auto_compound_removes_if_auto_compound_zero_percent() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + >::new(vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(10), + }]) + .set_storage(&1); + + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::zero(), + 1, + 1, + )); + assert_event_emitted!(Event::AutoCompoundSet { + candidate: 1, + delegator: 2, + value: Percent::zero(), + }); + assert_eq!(0, ParachainStaking::auto_compounding_delegations(&1).len(),); + }); +} + +#[test] +fn test_execute_revoke_delegation_removes_auto_compounding_from_state_for_delegation_revoke() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 30), (3, 20)]) + .with_candidates(vec![(1, 30), (3, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 2, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 3, + Percent::from_percent(50), + 0, + 2, + )); + assert_ok!(ParachainStaking::schedule_revoke_delegation( + Origin::signed(2), + 1 + )); + roll_to(10); + assert_ok!(ParachainStaking::execute_delegation_request( + Origin::signed(2), + 2, + 1 + )); + assert!( + !ParachainStaking::auto_compounding_delegations(&1) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + assert!( + ParachainStaking::auto_compounding_delegations(&3) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was erroneously removed" + ); + }); +} + +#[test] +fn test_execute_leave_delegators_removes_auto_compounding_state() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 20), (3, 20)]) + .with_candidates(vec![(1, 30), (3, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 2, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 3, + Percent::from_percent(50), + 0, + 2, + )); + + assert_ok!(ParachainStaking::schedule_leave_delegators(Origin::signed( + 2 + ))); + roll_to(10); + assert_ok!(ParachainStaking::execute_leave_delegators( + Origin::signed(2), + 2, + 2, + )); + + assert!( + !ParachainStaking::auto_compounding_delegations(&1) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + assert!( + !ParachainStaking::auto_compounding_delegations(&3) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + }); +} + +#[allow(deprecated)] +#[test] +fn test_execute_leave_delegators_with_deprecated_status_leaving_removes_auto_compounding_state() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 20), (3, 20)]) + .with_candidates(vec![(1, 30), (3, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 2, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 3, + Percent::from_percent(50), + 0, + 2, + )); + + >::mutate(2, |value| { + value.as_mut().map(|mut state| { + state.status = DelegatorStatus::Leaving(2); + }) + }); + roll_to(10); + assert_ok!(ParachainStaking::execute_leave_delegators( + Origin::signed(2), + 2, + 2, + )); + + assert!( + !ParachainStaking::auto_compounding_delegations(&1) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + assert!( + !ParachainStaking::auto_compounding_delegations(&3) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + }); +} + +#[test] +fn test_execute_leave_candidates_removes_auto_compounding_state() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 20), (3, 20)]) + .with_candidates(vec![(1, 30), (3, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 2, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 3, + Percent::from_percent(50), + 0, + 2, + )); + + assert_ok!(ParachainStaking::schedule_leave_candidates( + Origin::signed(1), + 2 + )); + roll_to(10); + assert_ok!(ParachainStaking::execute_leave_candidates( + Origin::signed(1), + 1, + 1, + )); + + assert!( + !ParachainStaking::auto_compounding_delegations(&1) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + assert!( + ParachainStaking::auto_compounding_delegations(&3) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was erroneously removed" + ); + }); +} + +#[test] +fn test_delegation_kicked_from_bottom_delegation_removes_auto_compounding_state() { + ExtBuilder::default() + .with_balances(vec![ + (1, 30), + (2, 29), + (3, 20), + (4, 20), + (5, 20), + (6, 20), + (7, 20), + (8, 20), + (9, 20), + (10, 20), + (11, 30), + ]) + .with_candidates(vec![(1, 30), (11, 30)]) + .with_delegations(vec![ + (2, 11, 10), // extra delegation to avoid leaving the delegator set + (2, 1, 19), + (3, 1, 20), + (4, 1, 20), + (5, 1, 20), + (6, 1, 20), + (7, 1, 20), + (8, 1, 20), + (9, 1, 20), + ]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 2, + )); + + // kicks lowest delegation (2, 19) + assert_ok!(ParachainStaking::delegate(Origin::signed(10), 1, 20, 8, 0)); + + assert!( + !ParachainStaking::auto_compounding_delegations(&1) + .iter() + .any(|x| x.delegator == 2), + "delegation auto-compound config was not removed" + ); + }); +} + +#[test] +fn test_rewards_do_not_auto_compound_on_payment_if_delegation_scheduled_revoke_exists() { + ExtBuilder::default() + .with_balances(vec![(1, 100), (2, 200), (3, 200)]) + .with_candidates(vec![(1, 100)]) + .with_delegations(vec![(2, 1, 200), (3, 1, 200)]) + .build() + .execute_with(|| { + (2..=5).for_each(|round| set_author(round, 1, 1)); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 1, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(3), + 1, + Percent::from_percent(50), + 1, + 1, + )); + roll_to_round_begin(3); + + // schedule revoke for delegator 2; no rewards should be compounded + assert_ok!(ParachainStaking::schedule_revoke_delegation( + Origin::signed(2), + 1 + )); + roll_to_round_begin(4); + + assert_eq_last_events!(vec![ + // no compound since revoke request exists + Event::::Rewarded { + account: 2, + rewards: 8, + }, + // 50% + Event::::Rewarded { + account: 3, + rewards: 8, + }, + Event::::Compounded { + candidate: 1, + delegator: 3, + amount: 4, + }, + ]); + }); +} + +#[test] +fn test_rewards_auto_compound_on_payment_as_per_auto_compound_config() { + ExtBuilder::default() + .with_balances(vec![(1, 100), (2, 200), (3, 200), (4, 200), (5, 200)]) + .with_candidates(vec![(1, 100)]) + .with_delegations(vec![(2, 1, 200), (3, 1, 200), (4, 1, 200), (5, 1, 200)]) + .build() + .execute_with(|| { + (2..=6).for_each(|round| set_author(round, 1, 1)); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(0), + 0, + 1, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(3), + 1, + Percent::from_percent(50), + 1, + 1, + )); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(4), + 1, + Percent::from_percent(100), + 2, + 1, + )); + roll_to_round_begin(4); + + assert_eq_last_events!(vec![ + // 0% + Event::::Rewarded { + account: 2, + rewards: 8, + }, + // 50% + Event::::Rewarded { + account: 3, + rewards: 8, + }, + Event::::Compounded { + candidate: 1, + delegator: 3, + amount: 4, + }, + // 100% + Event::::Rewarded { + account: 4, + rewards: 8, + }, + Event::::Compounded { + candidate: 1, + delegator: 4, + amount: 8, + }, + // no-config + Event::::Rewarded { + account: 5, + rewards: 8, + }, + ]); + }); +} + +#[allow(deprecated)] +#[test] +fn test_migrated_at_stake_handles_deprecated_storage_value() { + use crate::deprecated::CollatorSnapshot as DeprecatedCollatorSnapshot; + use crate::mock::{AccountId, Balance}; + + ExtBuilder::default() + .with_balances(vec![(1, 100), (2, 100)]) + .with_candidates(vec![(1, 100)]) + .with_delegations(vec![(2, 1, 100)]) + .build() + .execute_with(|| { + (1..=6).for_each(|round| set_author(round, 1, 1)); + roll_to_round_begin(1); + // force store deprecated CollatorSnapshot format for round 1 + frame_support::storage::unhashed::put( + &>::hashed_key_for(1, 1), + &DeprecatedCollatorSnapshot { + bond: 100 as Balance, + delegations: vec![Bond { + owner: 2 as AccountId, + amount: 100 as Balance, + }], + total: 200 as Balance, + }, + ); + // reset migrated at storage + >::kill(); + + // assert MigratedAtStake storage is updated at round 2, and set auto-compound value + roll_to_round_begin(2); + assert_eq!(Some(2), >::get()); + assert_ok!(ParachainStaking::set_auto_compound( + Origin::signed(2), + 1, + Percent::from_percent(50), + 0, + 1, + )); + + // assert rewards were correctly given with deprecated CollatorSnapshot + roll_to_round_begin(3); + assert_eq_last_events!( + vec![ + Event::::Rewarded { + account: 1, + rewards: 6, + }, + Event::::Rewarded { + account: 2, + rewards: 4, + }, + ], + "incorrect rewards at round 3 with deprecated CollatorSnapshot", + ); + + // assert rewards were correctly given with new CollatorSnapshot, but without compound + roll_to_round_begin(4); + assert_eq_last_events!( + vec![ + Event::::Rewarded { + account: 1, + rewards: 6, + }, + Event::::Rewarded { + account: 2, + rewards: 4, + }, + ], + "incorrect rewards without compounding at round 4", + ); + + // assert rewards were correctly given with new CollatorSnapshot and with compound + roll_to_round_begin(5); + assert_eq_last_events!( + vec![ + Event::::Rewarded { + account: 1, + rewards: 6, + }, + Event::::Rewarded { + account: 2, + rewards: 4, + }, + Event::::Compounded { + candidate: 1, + delegator: 2, + amount: 2, + }, + ], + "incorrect compounding rewards at round 5", + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_fails_if_invalid_delegation_hint() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25), (3, 30)]) + .with_candidates(vec![(1, 30), (3, 30)]) + .with_delegations(vec![(2, 3, 10)]) + .build() + .execute_with(|| { + let candidate_delegation_count_hint = 0; + let candidate_auto_compounding_delegation_count_hint = 0; + let delegation_hint = 0; // is however, 1 + + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + candidate_delegation_count_hint, + candidate_auto_compounding_delegation_count_hint, + delegation_hint, + ), + >::TooLowDelegationCountToDelegate, + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_fails_if_invalid_candidate_delegation_count_hint() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25), (3, 30)]) + .with_candidates(vec![(1, 30)]) + .with_delegations(vec![(3, 1, 10)]) + .build() + .execute_with(|| { + let candidate_delegation_count_hint = 0; // is however, 1 + let candidate_auto_compounding_delegation_count_hint = 0; + let delegation_hint = 0; + + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + candidate_delegation_count_hint, + candidate_auto_compounding_delegation_count_hint, + delegation_hint, + ), + >::TooLowCandidateDelegationCountToDelegate, + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_fails_if_invalid_candidate_auto_compounding_delegations_hint() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25), (3, 30)]) + .with_candidates(vec![(1, 30)]) + .with_auto_compounding_delegations(vec![(3, 1, 10, Percent::from_percent(10))]) + .build() + .execute_with(|| { + let candidate_delegation_count_hint = 1; + let candidate_auto_compounding_delegation_count_hint = 0; // is however, 1 + let delegation_hint = 0; + + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + candidate_delegation_count_hint, + candidate_auto_compounding_delegation_count_hint, + delegation_hint, + ), + >::TooLowCandidateAutoCompoundingDelegationCountToDelegate, + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_sets_auto_compound_config() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 25)]) + .with_candidates(vec![(1, 30)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 0, + 0, + )); + assert_event_emitted!(Event::Delegation { + delegator: 2, + locked_amount: 10, + candidate: 1, + delegator_position: DelegatorAdded::AddedToTop { new_total: 40 }, + auto_compound: Percent::from_percent(50), + }); + assert_eq!( + vec![AutoCompoundConfig { + delegator: 2, + value: Percent::from_percent(50), + }], + ParachainStaking::auto_compounding_delegations(&1), + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_skips_storage_but_emits_event_for_zero_auto_compound() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 10)]) + .with_candidates(vec![(1, 30)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::zero(), + 0, + 0, + 0, + )); + assert_eq!(0, ParachainStaking::auto_compounding_delegations(&1).len(),); + assert_last_event!(MetaEvent::ParachainStaking(Event::Delegation { + delegator: 2, + locked_amount: 10, + candidate: 1, + delegator_position: DelegatorAdded::AddedToTop { new_total: 40 }, + auto_compound: Percent::zero(), + })); + }); +} + +#[test] +fn test_delegate_with_auto_compound_reserves_balance() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 10)]) + .with_candidates(vec![(1, 30)]) + .build() + .execute_with(|| { + assert_eq!( + ParachainStaking::get_delegator_stakable_free_balance(&2), + 10 + ); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 0, + 0, + )); + assert_eq!(ParachainStaking::get_delegator_stakable_free_balance(&2), 0); + }); +} + +#[test] +fn test_delegate_with_auto_compound_updates_delegator_state() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 10)]) + .with_candidates(vec![(1, 30)]) + .build() + .execute_with(|| { + assert!(ParachainStaking::delegator_state(2).is_none()); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 0, + 0 + )); + let delegator_state = + ParachainStaking::delegator_state(2).expect("just delegated => exists"); + assert_eq!(delegator_state.total(), 10); + assert_eq!(delegator_state.delegations.0[0].owner, 1); + assert_eq!(delegator_state.delegations.0[0].amount, 10); + }); +} + +#[test] +fn test_delegate_with_auto_compound_updates_collator_state() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 10)]) + .with_candidates(vec![(1, 30)]) + .build() + .execute_with(|| { + let candidate_state = + ParachainStaking::candidate_info(1).expect("registered in genesis"); + assert_eq!(candidate_state.total_counted, 30); + let top_delegations = + ParachainStaking::top_delegations(1).expect("registered in genesis"); + assert!(top_delegations.delegations.is_empty()); + assert!(top_delegations.total.is_zero()); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 0, + 0 + )); + let candidate_state = + ParachainStaking::candidate_info(1).expect("just delegated => exists"); + assert_eq!(candidate_state.total_counted, 40); + let top_delegations = + ParachainStaking::top_delegations(1).expect("just delegated => exists"); + assert_eq!(top_delegations.delegations[0].owner, 2); + assert_eq!(top_delegations.delegations[0].amount, 10); + assert_eq!(top_delegations.total, 10); + }); +} + +#[test] +fn test_delegate_with_auto_compound_can_delegate_immediately_after_other_join_candidates() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 20)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::join_candidates(Origin::signed(1), 20, 0)); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 20, + Percent::from_percent(50), + 0, + 0, + 0 + )); + }); +} + +#[test] +fn test_delegate_with_auto_compound_can_delegate_to_other_if_revoking() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 30), (3, 20), (4, 20)]) + .with_candidates(vec![(1, 20), (3, 20), (4, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::schedule_revoke_delegation( + Origin::signed(2), + 1 + )); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 4, + 10, + Percent::from_percent(50), + 0, + 0, + 2 + )); + }); +} + +#[test] +fn test_delegate_with_auto_compound_cannot_delegate_if_less_than_or_equal_lowest_bottom() { + ExtBuilder::default() + .with_balances(vec![ + (1, 20), + (2, 10), + (3, 10), + (4, 10), + (5, 10), + (6, 10), + (7, 10), + (8, 10), + (9, 10), + (10, 10), + (11, 10), + ]) + .with_candidates(vec![(1, 20)]) + .with_delegations(vec![ + (2, 1, 10), + (3, 1, 10), + (4, 1, 10), + (5, 1, 10), + (6, 1, 10), + (8, 1, 10), + (9, 1, 10), + (10, 1, 10), + ]) + .build() + .execute_with(|| { + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(11), + 1, + 10, + Percent::from_percent(50), + 8, + 0, + 0 + ), + Error::::CannotDelegateLessThanOrEqualToLowestBottomWhenFull + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_can_delegate_if_greater_than_lowest_bottom() { + ExtBuilder::default() + .with_balances(vec![ + (1, 20), + (2, 10), + (3, 10), + (4, 10), + (5, 10), + (6, 10), + (7, 10), + (8, 10), + (9, 10), + (10, 10), + (11, 11), + ]) + .with_candidates(vec![(1, 20)]) + .with_delegations(vec![ + (2, 1, 10), + (3, 1, 10), + (4, 1, 10), + (5, 1, 10), + (6, 1, 10), + (8, 1, 10), + (9, 1, 10), + (10, 1, 10), + ]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(11), + 1, + 11, + Percent::from_percent(50), + 8, + 0, + 0 + )); + assert_event_emitted!(Event::DelegationKicked { + delegator: 10, + candidate: 1, + unstaked_amount: 10 + }); + assert_event_emitted!(Event::DelegatorLeft { + delegator: 10, + unstaked_amount: 10 + }); + }); +} + +#[test] +fn test_delegate_with_auto_compound_can_still_delegate_to_other_if_leaving() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 20), (3, 20)]) + .with_candidates(vec![(1, 20), (3, 20)]) + .with_delegations(vec![(2, 1, 10)]) + .build() + .execute_with(|| { + assert_ok!(ParachainStaking::schedule_leave_delegators(Origin::signed( + 2 + ))); + assert_ok!(ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 3, + 10, + Percent::from_percent(50), + 0, + 0, + 1 + ),); + }); +} + +#[test] +fn test_delegate_with_auto_compound_cannot_delegate_if_candidate() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 30)]) + .with_candidates(vec![(1, 20), (2, 20)]) + .build() + .execute_with(|| { + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 0, + 0 + ), + Error::::CandidateExists + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_cannot_delegate_if_already_delegated() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 30)]) + .with_candidates(vec![(1, 20)]) + .with_delegations(vec![(2, 1, 20)]) + .build() + .execute_with(|| { + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 1, + 10, + Percent::from_percent(50), + 0, + 1, + 1 + ), + Error::::AlreadyDelegatedCandidate + ); + }); +} + +#[test] +fn test_delegate_with_auto_compound_cannot_delegate_more_than_max_delegations() { + ExtBuilder::default() + .with_balances(vec![(1, 20), (2, 50), (3, 20), (4, 20), (5, 20), (6, 20)]) + .with_candidates(vec![(1, 20), (3, 20), (4, 20), (5, 20), (6, 20)]) + .with_delegations(vec![(2, 1, 10), (2, 3, 10), (2, 4, 10), (2, 5, 10)]) + .build() + .execute_with(|| { + assert_noop!( + ParachainStaking::delegate_with_auto_compound( + Origin::signed(2), + 6, + 10, + Percent::from_percent(50), + 0, + 0, + 4 + ), + Error::::ExceedMaxDelegationsPerDelegator, + ); + }); +} + +#[test] +fn test_delegate_skips_auto_compound_storage_but_emits_event_for_zero_auto_compound() { + ExtBuilder::default() + .with_balances(vec![(1, 30), (2, 20), (3, 30)]) + .with_candidates(vec![(1, 30)]) + .with_auto_compounding_delegations(vec![(3, 1, 10, Percent::from_percent(50))]) + .build() + .execute_with(|| { + // We already have an auto-compounding delegation from 3 -> 1, so the hint validation + // would cause a failure if the auto-compounding isn't skipped properly. + assert_ok!(ParachainStaking::delegate(Origin::signed(2), 1, 10, 1, 0,)); + assert_eq!(1, ParachainStaking::auto_compounding_delegations(&1).len(),); + assert_last_event!(MetaEvent::ParachainStaking(Event::Delegation { + delegator: 2, + locked_amount: 10, + candidate: 1, + delegator_position: DelegatorAdded::AddedToTop { new_total: 50 }, + auto_compound: Percent::zero(), + })); + }); +} diff --git a/pallets/parachain-staking/src/types.rs b/pallets/parachain-staking/src/types.rs index bd6e6ea324..dbb673e708 100644 --- a/pallets/parachain-staking/src/types.rs +++ b/pallets/parachain-staking/src/types.rs @@ -17,8 +17,9 @@ //! Types for parachain-staking use crate::{ - set::OrderedSet, BalanceOf, BottomDelegations, CandidateInfo, Config, DelegatorState, Error, - Event, Pallet, Round, RoundIndex, TopDelegations, Total, COLLATOR_LOCK_ID, DELEGATOR_LOCK_ID, + auto_compound::AutoCompoundDelegations, set::OrderedSet, BalanceOf, BottomDelegations, + CandidateInfo, Config, DelegatorState, Error, Event, Pallet, Round, RoundIndex, TopDelegations, + Total, COLLATOR_LOCK_ID, DELEGATOR_LOCK_ID, }; use frame_support::{ pallet_prelude::*, @@ -98,6 +99,24 @@ impl Default for CollatorStatus { } } +#[derive(Clone, Encode, Decode, RuntimeDebug, TypeInfo)] +pub struct BondWithAutoCompound { + pub owner: AccountId, + pub amount: Balance, + pub auto_compound: Percent, +} + +impl Default for BondWithAutoCompound { + fn default() -> BondWithAutoCompound { + BondWithAutoCompound { + owner: A::decode(&mut sp_runtime::traits::TrailingZeroInput::zeroes()) + .expect("infinite length input; no invalid inputs for type; qed"), + amount: B::default(), + auto_compound: Percent::zero(), + } + } +} + #[derive(Encode, Decode, RuntimeDebug, TypeInfo)] /// Snapshot of collator state at the start of the round for which they are selected pub struct CollatorSnapshot { @@ -107,7 +126,7 @@ pub struct CollatorSnapshot { /// The rewardable delegations. This list is a subset of total delegators, where certain /// delegators are adjusted based on their scheduled /// [DelegationChange::Revoke] or [DelegationChange::Decrease] action. - pub delegations: Vec>, + pub delegations: Vec>, /// The total counted value locked for the collator, including the self bond + total staked by /// top delegators. @@ -121,17 +140,19 @@ impl PartialEq for CollatorSnapshot { return false; } for ( - Bond { + BondWithAutoCompound { owner: o1, amount: a1, + auto_compound: c1, }, - Bond { + BondWithAutoCompound { owner: o2, amount: a2, + auto_compound: c2, }, ) in self.delegations.iter().zip(other.delegations.iter()) { - if o1 != o2 || a1 != a2 { + if o1 != o2 || a1 != a2 || c1 != c2 { return false; } } @@ -677,6 +698,10 @@ impl< &lowest_bottom_to_be_kicked.owner, &mut delegator_state, ); + >::remove_auto_compound( + &candidate, + &lowest_bottom_to_be_kicked.owner, + ); Pallet::::deposit_event(Event::DelegationKicked { delegator: lowest_bottom_to_be_kicked.owner.clone(), @@ -1208,7 +1233,15 @@ impl From> for CollatorSnapshot fn from(other: CollatorCandidate) -> CollatorSnapshot { CollatorSnapshot { bond: other.bond, - delegations: other.top_delegations, + delegations: other + .top_delegations + .into_iter() + .map(|d| BondWithAutoCompound { + owner: d.owner, + amount: d.amount, + auto_compound: Percent::zero(), + }) + .collect(), total: other.total_counted, } } @@ -1387,11 +1420,14 @@ impl< None } } + + /// Increases the delegation amount and returns `true` if the delegation is part of the + /// TopDelegations set, `false` otherwise. pub fn increase_delegation( &mut self, candidate: AccountId, amount: Balance, - ) -> DispatchResult + ) -> Result where BalanceOf: From, T::AccountId: From, @@ -1427,13 +1463,7 @@ impl< >::put(new_total_staked); let nom_st: Delegator> = self.clone().into(); >::insert(&delegator_id, nom_st); - Pallet::::deposit_event(Event::DelegationIncreased { - delegator: delegator_id, - candidate: candidate_id, - amount: balance_amt, - in_top: in_top, - }); - return Ok(()); + return Ok(in_top); } } Err(Error::::DelegationDNE.into()) @@ -1575,6 +1605,60 @@ pub mod deprecated { /// Status for this delegator pub status: DelegatorStatus, } + + // CollatorSnapshot + + #[deprecated(note = "use CollatorSnapshot with BondWithAutoCompound delegations")] + #[derive(Encode, Decode, RuntimeDebug, TypeInfo)] + /// Snapshot of collator state at the start of the round for which they are selected + pub struct CollatorSnapshot { + /// The total value locked by the collator. + pub bond: Balance, + + /// The rewardable delegations. This list is a subset of total delegators, where certain + /// delegators are adjusted based on their scheduled + /// [DelegationChange::Revoke] or [DelegationChange::Decrease] action. + pub delegations: Vec>, + + /// The total counted value locked for the collator, including the self bond + total staked by + /// top delegators. + pub total: Balance, + } + + impl PartialEq for CollatorSnapshot { + fn eq(&self, other: &Self) -> bool { + let must_be_true = self.bond == other.bond && self.total == other.total; + if !must_be_true { + return false; + } + for ( + Bond { + owner: o1, + amount: a1, + }, + Bond { + owner: o2, + amount: a2, + }, + ) in self.delegations.iter().zip(other.delegations.iter()) + { + if o1 != o2 || a1 != a2 { + return false; + } + } + true + } + } + + impl Default for CollatorSnapshot { + fn default() -> CollatorSnapshot { + CollatorSnapshot { + bond: B::default(), + delegations: Vec::new(), + total: B::default(), + } + } + } } #[derive(Clone, Encode, Decode, RuntimeDebug, TypeInfo)] diff --git a/pallets/parachain-staking/src/weights.rs b/pallets/parachain-staking/src/weights.rs index dc6c21bf2c..260d538998 100644 --- a/pallets/parachain-staking/src/weights.rs +++ b/pallets/parachain-staking/src/weights.rs @@ -17,7 +17,7 @@ //! Autogenerated weights for parachain_staking //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev -//! DATE: 2022-09-28, STEPS: `50`, REPEAT: 20, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2022-10-10, STEPS: `50`, REPEAT: 20, LOW RANGE: `[]`, HIGH RANGE: `[]` //! EXECUTION: Some(Wasm), WASM-EXECUTION: Compiled, CHAIN: None, DB CACHE: 1024 // Executed Command: @@ -27,7 +27,7 @@ // --execution=wasm // --wasm-execution=compiled // --pallet -// * +// parachain_staking // --extrinsic // * // --steps @@ -38,7 +38,7 @@ // --json-file // raw.json // --output -// ./tmp/ +// weights.rs #![allow(unused_parens)] #![allow(unused_imports)] @@ -113,6 +113,10 @@ pub trait WeightInfo { fn pay_one_collator_reward(y: u32, ) -> Weight; #[rustfmt::skip] fn base_on_initialize() -> Weight; + #[rustfmt::skip] + fn set_auto_compound(x: u32, y: u32, ) -> Weight; + #[rustfmt::skip] + fn delegate_with_auto_compound(x: u32, y: u32, z: u32, ) -> Weight; } /// Weights for parachain_staking using the Substrate node and recommended hardware. @@ -200,16 +204,17 @@ impl WeightInfo for SubstrateWeight { // Storage: Balances Locks (r:2 w:2) // Storage: System Account (r:2 w:2) // Storage: ParachainStaking DelegationScheduledRequests (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) // Storage: ParachainStaking BottomDelegations (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) #[rustfmt::skip] fn execute_leave_candidates(x: u32, ) -> Weight { Weight::from_ref_time(0 as u64) // Standard Error: 87_000 - .saturating_add(Weight::from_ref_time(30_331_000 as u64).saturating_mul(x as u64)) - .saturating_add(T::DbWeight::get().reads(4 as u64)) + .saturating_add(Weight::from_ref_time(31_860_000 as u64).saturating_mul(x as u64)) + .saturating_add(T::DbWeight::get().reads(5 as u64)) .saturating_add(T::DbWeight::get().reads((3 as u64).saturating_mul(x as u64))) - .saturating_add(T::DbWeight::get().writes(4 as u64)) + .saturating_add(T::DbWeight::get().writes(5 as u64)) .saturating_add(T::DbWeight::get().writes((3 as u64).saturating_mul(x as u64))) } // Storage: ParachainStaking CandidateInfo (r:1 w:1) @@ -305,15 +310,16 @@ impl WeightInfo for SubstrateWeight { // Storage: ParachainStaking TopDelegations (r:1 w:1) // Storage: ParachainStaking CandidatePool (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:0) // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) #[rustfmt::skip] fn execute_leave_delegators(x: u32, ) -> Weight { - Weight::from_ref_time(19_205_000 as u64) - // Standard Error: 24_000 - .saturating_add(Weight::from_ref_time(25_795_000 as u64).saturating_mul(x as u64)) - .saturating_add(T::DbWeight::get().reads(2 as u64)) - .saturating_add(T::DbWeight::get().reads((3 as u64).saturating_mul(x as u64))) + Weight::from_ref_time(18_201_000 as u64) + // Standard Error: 22_000 + .saturating_add(Weight::from_ref_time(27_748_000 as u64).saturating_mul(x as u64)) + .saturating_add(T::DbWeight::get().reads(1 as u64)) + .saturating_add(T::DbWeight::get().reads((4 as u64).saturating_mul(x as u64))) .saturating_add(T::DbWeight::get().writes(2 as u64)) .saturating_add(T::DbWeight::get().writes((3 as u64).saturating_mul(x as u64))) } @@ -359,14 +365,15 @@ impl WeightInfo for SubstrateWeight { // Storage: ParachainStaking DelegationScheduledRequests (r:1 w:1) // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:0) // Storage: ParachainStaking CandidateInfo (r:1 w:1) // Storage: ParachainStaking TopDelegations (r:1 w:1) // Storage: ParachainStaking CandidatePool (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) #[rustfmt::skip] fn execute_revoke_delegation() -> Weight { - Weight::from_ref_time(103_391_000 as u64) - .saturating_add(T::DbWeight::get().reads(8 as u64)) + Weight::from_ref_time(107_376_000 as u64) + .saturating_add(T::DbWeight::get().reads(9 as u64)) .saturating_add(T::DbWeight::get().writes(8 as u64)) } // Storage: ParachainStaking DelegatorState (r:1 w:1) @@ -411,6 +418,8 @@ impl WeightInfo for SubstrateWeight { // Storage: ParachainStaking CandidateInfo (r:9 w:0) // Storage: ParachainStaking DelegationScheduledRequests (r:9 w:0) // Storage: ParachainStaking TopDelegations (r:9 w:0) + // Storage: ParachainStaking AutoCompoundingDelegations (r:9 w:0) + // Storage: ParachainStaking MigratedAtStake (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:0) // Storage: ParachainStaking AwardedPts (r:2 w:1) // Storage: ParachainStaking AtStake (r:1 w:10) @@ -420,35 +429,67 @@ impl WeightInfo for SubstrateWeight { // Storage: ParachainStaking DelayedPayouts (r:0 w:1) #[rustfmt::skip] fn round_transition_on_initialize(x: u32, y: u32, ) -> Weight { - Weight::from_ref_time(593_877_000 as u64) - // Standard Error: 1_127_000 - .saturating_add(Weight::from_ref_time(41_779_000 as u64).saturating_mul(x as u64)) + Weight::from_ref_time(363_268_000 as u64) + // Standard Error: 1_140_000 + .saturating_add(Weight::from_ref_time(47_699_000 as u64).saturating_mul(x as u64)) // Standard Error: 3_000 - .saturating_add(Weight::from_ref_time(131_000 as u64).saturating_mul(y as u64)) - .saturating_add(T::DbWeight::get().reads(179 as u64)) - .saturating_add(T::DbWeight::get().reads((3 as u64).saturating_mul(x as u64))) - .saturating_add(T::DbWeight::get().writes(171 as u64)) + .saturating_add(Weight::from_ref_time(142_000 as u64).saturating_mul(y as u64)) + .saturating_add(T::DbWeight::get().reads(181 as u64)) + .saturating_add(T::DbWeight::get().reads((4 as u64).saturating_mul(x as u64))) + .saturating_add(T::DbWeight::get().writes(172 as u64)) .saturating_add(T::DbWeight::get().writes((1 as u64).saturating_mul(x as u64))) } // Storage: ParachainStaking DelayedPayouts (r:1 w:0) // Storage: ParachainStaking Points (r:1 w:0) // Storage: ParachainStaking AwardedPts (r:2 w:1) + // Storage: ParachainStaking MigratedAtStake (r:1 w:0) // Storage: ParachainStaking AtStake (r:1 w:1) // Storage: System Account (r:1 w:1) // Storage: MoonbeamOrbiters OrbiterPerRound (r:1 w:0) #[rustfmt::skip] fn pay_one_collator_reward(y: u32, ) -> Weight { - Weight::from_ref_time(59_395_000 as u64) - // Standard Error: 8_000 - .saturating_add(Weight::from_ref_time(15_522_000 as u64).saturating_mul(y as u64)) - .saturating_add(T::DbWeight::get().reads(7 as u64)) + Weight::from_ref_time(61_374_000 as u64) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(15_651_000 as u64).saturating_mul(y as u64)) + .saturating_add(T::DbWeight::get().reads(8 as u64)) .saturating_add(T::DbWeight::get().reads((1 as u64).saturating_mul(y as u64))) .saturating_add(T::DbWeight::get().writes(3 as u64)) .saturating_add(T::DbWeight::get().writes((1 as u64).saturating_mul(y as u64))) } #[rustfmt::skip] fn base_on_initialize() -> Weight { - Weight::from_ref_time(10_245_000 as u64) + Weight::from_ref_time(11_002_000 as u64) + } + // Storage: ParachainStaking DelegatorState (r:1 w:0) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) + #[rustfmt::skip] + fn set_auto_compound(x: u32, y: u32, ) -> Weight { + Weight::from_ref_time(61_986_000 as u64) + // Standard Error: 4_000 + .saturating_add(Weight::from_ref_time(244_000 as u64).saturating_mul(x as u64)) + // Standard Error: 14_000 + .saturating_add(Weight::from_ref_time(216_000 as u64).saturating_mul(y as u64)) + .saturating_add(T::DbWeight::get().reads(2 as u64)) + .saturating_add(T::DbWeight::get().writes(1 as u64)) + } + // Storage: System Account (r:1 w:1) + // Storage: ParachainStaking DelegatorState (r:1 w:1) + // Storage: ParachainStaking CandidateInfo (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) + // Storage: ParachainStaking TopDelegations (r:1 w:1) + // Storage: ParachainStaking CandidatePool (r:1 w:1) + // Storage: Balances Locks (r:1 w:1) + // Storage: ParachainStaking Total (r:1 w:1) + // Storage: ParachainStaking BottomDelegations (r:1 w:1) + #[rustfmt::skip] + fn delegate_with_auto_compound(x: u32, y: u32, _z: u32, ) -> Weight { + Weight::from_ref_time(168_431_000 as u64) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(73_000 as u64).saturating_mul(x as u64)) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(71_000 as u64).saturating_mul(y as u64)) + .saturating_add(T::DbWeight::get().reads(8 as u64)) + .saturating_add(T::DbWeight::get().writes(8 as u64)) } } @@ -536,16 +577,17 @@ impl WeightInfo for () { // Storage: Balances Locks (r:2 w:2) // Storage: System Account (r:2 w:2) // Storage: ParachainStaking DelegationScheduledRequests (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) // Storage: ParachainStaking BottomDelegations (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) #[rustfmt::skip] fn execute_leave_candidates(x: u32, ) -> Weight { Weight::from_ref_time(0 as u64) // Standard Error: 87_000 - .saturating_add(Weight::from_ref_time(30_331_000 as u64).saturating_mul(x as u64)) - .saturating_add(RocksDbWeight::get().reads(4 as u64)) + .saturating_add(Weight::from_ref_time(31_860_000 as u64).saturating_mul(x as u64)) + .saturating_add(RocksDbWeight::get().reads(5 as u64)) .saturating_add(RocksDbWeight::get().reads((3 as u64).saturating_mul(x as u64))) - .saturating_add(RocksDbWeight::get().writes(4 as u64)) + .saturating_add(RocksDbWeight::get().writes(5 as u64)) .saturating_add(RocksDbWeight::get().writes((3 as u64).saturating_mul(x as u64))) } // Storage: ParachainStaking CandidateInfo (r:1 w:1) @@ -641,15 +683,16 @@ impl WeightInfo for () { // Storage: ParachainStaking TopDelegations (r:1 w:1) // Storage: ParachainStaking CandidatePool (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:0) // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) #[rustfmt::skip] fn execute_leave_delegators(x: u32, ) -> Weight { - Weight::from_ref_time(19_205_000 as u64) - // Standard Error: 24_000 - .saturating_add(Weight::from_ref_time(25_795_000 as u64).saturating_mul(x as u64)) - .saturating_add(RocksDbWeight::get().reads(2 as u64)) - .saturating_add(RocksDbWeight::get().reads((3 as u64).saturating_mul(x as u64))) + Weight::from_ref_time(18_201_000 as u64) + // Standard Error: 22_000 + .saturating_add(Weight::from_ref_time(27_748_000 as u64).saturating_mul(x as u64)) + .saturating_add(RocksDbWeight::get().reads(1 as u64)) + .saturating_add(RocksDbWeight::get().reads((4 as u64).saturating_mul(x as u64))) .saturating_add(RocksDbWeight::get().writes(2 as u64)) .saturating_add(RocksDbWeight::get().writes((3 as u64).saturating_mul(x as u64))) } @@ -695,14 +738,15 @@ impl WeightInfo for () { // Storage: ParachainStaking DelegationScheduledRequests (r:1 w:1) // Storage: Balances Locks (r:1 w:1) // Storage: System Account (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:0) // Storage: ParachainStaking CandidateInfo (r:1 w:1) // Storage: ParachainStaking TopDelegations (r:1 w:1) // Storage: ParachainStaking CandidatePool (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:1) #[rustfmt::skip] fn execute_revoke_delegation() -> Weight { - Weight::from_ref_time(103_391_000 as u64) - .saturating_add(RocksDbWeight::get().reads(8 as u64)) + Weight::from_ref_time(107_376_000 as u64) + .saturating_add(RocksDbWeight::get().reads(9 as u64)) .saturating_add(RocksDbWeight::get().writes(8 as u64)) } // Storage: ParachainStaking DelegatorState (r:1 w:1) @@ -747,6 +791,8 @@ impl WeightInfo for () { // Storage: ParachainStaking CandidateInfo (r:9 w:0) // Storage: ParachainStaking DelegationScheduledRequests (r:9 w:0) // Storage: ParachainStaking TopDelegations (r:9 w:0) + // Storage: ParachainStaking AutoCompoundingDelegations (r:9 w:0) + // Storage: ParachainStaking MigratedAtStake (r:1 w:1) // Storage: ParachainStaking Total (r:1 w:0) // Storage: ParachainStaking AwardedPts (r:2 w:1) // Storage: ParachainStaking AtStake (r:1 w:10) @@ -756,34 +802,66 @@ impl WeightInfo for () { // Storage: ParachainStaking DelayedPayouts (r:0 w:1) #[rustfmt::skip] fn round_transition_on_initialize(x: u32, y: u32, ) -> Weight { - Weight::from_ref_time(593_877_000 as u64) - // Standard Error: 1_127_000 - .saturating_add(Weight::from_ref_time(41_779_000 as u64).saturating_mul(x as u64)) + Weight::from_ref_time(363_268_000 as u64) + // Standard Error: 1_140_000 + .saturating_add(Weight::from_ref_time(47_699_000 as u64).saturating_mul(x as u64)) // Standard Error: 3_000 - .saturating_add(Weight::from_ref_time(131_000 as u64).saturating_mul(y as u64)) - .saturating_add(RocksDbWeight::get().reads(179 as u64)) - .saturating_add(RocksDbWeight::get().reads((3 as u64).saturating_mul(x as u64))) - .saturating_add(RocksDbWeight::get().writes(171 as u64)) + .saturating_add(Weight::from_ref_time(142_000 as u64).saturating_mul(y as u64)) + .saturating_add(RocksDbWeight::get().reads(181 as u64)) + .saturating_add(RocksDbWeight::get().reads((4 as u64).saturating_mul(x as u64))) + .saturating_add(RocksDbWeight::get().writes(172 as u64)) .saturating_add(RocksDbWeight::get().writes((1 as u64).saturating_mul(x as u64))) } // Storage: ParachainStaking DelayedPayouts (r:1 w:0) // Storage: ParachainStaking Points (r:1 w:0) // Storage: ParachainStaking AwardedPts (r:2 w:1) + // Storage: ParachainStaking MigratedAtStake (r:1 w:0) // Storage: ParachainStaking AtStake (r:1 w:1) // Storage: System Account (r:1 w:1) // Storage: MoonbeamOrbiters OrbiterPerRound (r:1 w:0) #[rustfmt::skip] fn pay_one_collator_reward(y: u32, ) -> Weight { - Weight::from_ref_time(59_395_000 as u64) - // Standard Error: 8_000 - .saturating_add(Weight::from_ref_time(15_522_000 as u64).saturating_mul(y as u64)) - .saturating_add(RocksDbWeight::get().reads(7 as u64)) + Weight::from_ref_time(61_374_000 as u64) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(15_651_000 as u64).saturating_mul(y as u64)) + .saturating_add(RocksDbWeight::get().reads(8 as u64)) .saturating_add(RocksDbWeight::get().reads((1 as u64).saturating_mul(y as u64))) .saturating_add(RocksDbWeight::get().writes(3 as u64)) .saturating_add(RocksDbWeight::get().writes((1 as u64).saturating_mul(y as u64))) } #[rustfmt::skip] fn base_on_initialize() -> Weight { - Weight::from_ref_time(10_245_000 as u64) + Weight::from_ref_time(11_002_000 as u64) + } + // Storage: ParachainStaking DelegatorState (r:1 w:0) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) + #[rustfmt::skip] + fn set_auto_compound(x: u32, y: u32, ) -> Weight { + Weight::from_ref_time(61_986_000 as u64) + // Standard Error: 4_000 + .saturating_add(Weight::from_ref_time(244_000 as u64).saturating_mul(x as u64)) + // Standard Error: 14_000 + .saturating_add(Weight::from_ref_time(216_000 as u64).saturating_mul(y as u64)) + .saturating_add(RocksDbWeight::get().reads(2 as u64)) + .saturating_add(RocksDbWeight::get().writes(1 as u64)) + } + // Storage: System Account (r:1 w:1) + // Storage: ParachainStaking DelegatorState (r:1 w:1) + // Storage: ParachainStaking CandidateInfo (r:1 w:1) + // Storage: ParachainStaking AutoCompoundingDelegations (r:1 w:1) + // Storage: ParachainStaking TopDelegations (r:1 w:1) + // Storage: ParachainStaking CandidatePool (r:1 w:1) + // Storage: Balances Locks (r:1 w:1) + // Storage: ParachainStaking Total (r:1 w:1) + // Storage: ParachainStaking BottomDelegations (r:1 w:1) + #[rustfmt::skip] + fn delegate_with_auto_compound(x: u32, y: u32, _z: u32, ) -> Weight { + Weight::from_ref_time(168_431_000 as u64) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(73_000 as u64).saturating_mul(x as u64)) + // Standard Error: 5_000 + .saturating_add(Weight::from_ref_time(71_000 as u64).saturating_mul(y as u64)) + .saturating_add(RocksDbWeight::get().reads(8 as u64)) + .saturating_add(RocksDbWeight::get().writes(8 as u64)) } } diff --git a/precompiles/parachain-staking/StakingInterface.sol b/precompiles/parachain-staking/StakingInterface.sol index 7e45d15bad..1c839a20f2 100644 --- a/precompiles/parachain-staking/StakingInterface.sol +++ b/precompiles/parachain-staking/StakingInterface.sol @@ -101,6 +101,16 @@ interface ParachainStaking { view returns (bool); + /// @dev Returns the percent value of auto-compound set for a delegation + /// @custom:selector b4d4c7fd + /// @param delegator the delegator that made the delegation + /// @param candidate the candidate for which the delegation was made + /// @return Percent of rewarded amount that is auto-compounded on each payout + function delegationAutoCompound(address delegator, address candidate) + external + view + returns (uint8); + /// @dev Join the set of collator candidates /// @custom:selector 1f2f83ad /// @param amount The amount self-bonded by the caller to become a collator candidate @@ -166,6 +176,24 @@ interface ParachainStaking { uint256 delegatorDelegationCount ) external; + /// @dev Make a delegation in support of a collator candidate + /// @custom:selector 4b8bc9bf + /// @param candidate The address of the supported collator candidate + /// @param amount The amount bonded in support of the collator candidate + /// @param autoCompound The percent of reward that should be auto-compounded + /// @param candidateDelegationCount The number of delegations in support of the candidate + /// @param candidateAutoCompoundingDelegationCount The number of auto-compounding delegations + /// in support of the candidate + /// @param delegatorDelegationCount The number of existing delegations by the caller + function delegateWithAutoCompound( + address candidate, + uint256 amount, + uint8 autoCompound, + uint256 candidateDelegationCount, + uint256 candidateAutoCompoundingDelegationCount, + uint256 delegatorDelegationCount + ) external; + /// @notice DEPRECATED use batch util with scheduleRevokeDelegation for all delegations /// @dev Request to leave the set of delegators /// @custom:selector f939dadb @@ -215,4 +243,18 @@ interface ParachainStaking { /// @custom:selector c90eee83 /// @param candidate The address of the candidate function cancelDelegationRequest(address candidate) external; + + /// @dev Sets an auto-compound value for a delegation + /// @custom:selector faa1786f + /// @param candidate The address of the supported collator candidate + /// @param value The percent of reward that should be auto-compounded + /// @param candidateAutoCompoundingDelegationCount The number of auto-compounding delegations + /// in support of the candidate + /// @param delegatorDelegationCount The number of existing delegations by the caller + function setAutoCompound( + address candidate, + uint8 value, + uint256 candidateAutoCompoundingDelegationCount, + uint256 delegatorDelegationCount + ) external; } diff --git a/precompiles/parachain-staking/src/lib.rs b/precompiles/parachain-staking/src/lib.rs index 12f0154a40..9b42423470 100644 --- a/precompiles/parachain-staking/src/lib.rs +++ b/precompiles/parachain-staking/src/lib.rs @@ -26,6 +26,7 @@ mod tests; use fp_evm::PrecompileHandle; use frame_support::dispatch::{Dispatchable, GetDispatchInfo, PostDispatchInfo}; +use frame_support::sp_runtime::Percent; use frame_support::traits::{Currency, Get}; use pallet_evm::AddressMapping; use precompile_utils::prelude::*; @@ -322,6 +323,26 @@ where Ok(pending) } + #[precompile::public("delegationAutoCompound(address,address)")] + #[precompile::view] + fn delegation_auto_compound( + handle: &mut impl PrecompileHandle, + delegator: Address, + candidate: Address, + ) -> EvmResult { + let delegator = Runtime::AddressMapping::into_account_id(delegator.0); + let candidate = Runtime::AddressMapping::into_account_id(candidate.0); + + // Fetch info. + handle.record_cost(RuntimeHelper::::db_read_gas_cost())?; + + let value = >::delegation_auto_compound( + &candidate, &delegator, + ); + + Ok(value.deconstruct()) + } + // Runtime Methods (dispatchables) #[precompile::public("joinCandidates(uint256,uint256)")] @@ -526,6 +547,42 @@ where Ok(()) } + #[precompile::public("delegateWithAutoCompound(address,uint256,uint8,uint256,uint256,uint256)")] + fn delegate_with_auto_compound( + handle: &mut impl PrecompileHandle, + candidate: Address, + amount: U256, + auto_compound: u8, + candidate_delegation_count: SolidityConvert, + candidate_auto_compounding_delegation_count: SolidityConvert, + delegator_delegation_count: SolidityConvert, + ) -> EvmResult { + let amount = Self::u256_to_amount(amount).in_field("amount")?; + let auto_compound = Percent::from_percent(auto_compound); + let candidate_delegation_count = candidate_delegation_count.converted(); + let candidate_auto_compounding_delegation_count = + candidate_auto_compounding_delegation_count.converted(); + let delegator_delegation_count = delegator_delegation_count.converted(); + + let candidate = Runtime::AddressMapping::into_account_id(candidate.0); + + // Build call with origin. + let origin = Runtime::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_parachain_staking::Call::::delegate_with_auto_compound { + candidate, + amount, + auto_compound, + candidate_delegation_count, + candidate_auto_compounding_delegation_count, + delegation_count: delegator_delegation_count, + }; + + // Dispatch call (if enough gas). + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(()) + } + /// Deprecated in favor of batch util #[precompile::public("scheduleLeaveDelegators()")] #[precompile::public("schedule_leave_delegators()")] @@ -684,6 +741,36 @@ where Ok(()) } + #[precompile::public("setAutoCompound(address,uint8,uint256,uint256)")] + fn set_auto_compound( + handle: &mut impl PrecompileHandle, + candidate: Address, + value: u8, + candidate_auto_compounding_delegation_count: SolidityConvert, + delegator_delegation_count: SolidityConvert, + ) -> EvmResult { + let value = Percent::from_percent(value); + let candidate_auto_compounding_delegation_count_hint = + candidate_auto_compounding_delegation_count.converted(); + let delegation_count_hint = delegator_delegation_count.converted(); + + let candidate = Runtime::AddressMapping::into_account_id(candidate.0); + + // Build call with origin. + let origin = Runtime::AddressMapping::into_account_id(handle.context().caller); + let call = pallet_parachain_staking::Call::::set_auto_compound { + candidate, + value, + candidate_auto_compounding_delegation_count_hint, + delegation_count_hint, + }; + + // Dispatch call (if enough gas). + RuntimeHelper::::try_dispatch(handle, Some(origin).into(), call)?; + + Ok(()) + } + fn u256_to_amount(value: U256) -> MayRevert> { value .try_into() diff --git a/precompiles/parachain-staking/src/mock.rs b/precompiles/parachain-staking/src/mock.rs index ab081088ef..e1309ebc16 100644 --- a/precompiles/parachain-staking/src/mock.rs +++ b/precompiles/parachain-staking/src/mock.rs @@ -275,7 +275,7 @@ pub(crate) struct ExtBuilder { // [collator, amount] collators: Vec<(AccountId, Balance)>, // [delegator, collator, delegation_amount] - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, // inflation config inflation: InflationInfo, } @@ -324,7 +324,21 @@ impl ExtBuilder { mut self, delegations: Vec<(AccountId, AccountId, Balance)>, ) -> Self { - self.delegations = delegations; + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, Percent::zero())) + .collect(); + self + } + + pub(crate) fn with_auto_compounding_delegations( + mut self, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, + ) -> Self { + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, d.3)) + .collect(); self } diff --git a/precompiles/parachain-staking/src/tests.rs b/precompiles/parachain-staking/src/tests.rs index 6ff57777cc..e8d0d4a412 100644 --- a/precompiles/parachain-staking/src/tests.rs +++ b/precompiles/parachain-staking/src/tests.rs @@ -19,6 +19,8 @@ use crate::mock::{ Account::{self, Alice, Bob, Bogus, Charlie, Precompile}, Call, ExtBuilder, Origin, PCall, ParachainStaking, PrecompilesValue, Runtime, TestPrecompiles, }; +use core::str::from_utf8; +use frame_support::sp_runtime::Percent; use frame_support::{assert_ok, dispatch::Dispatchable}; use pallet_evm::Call as EvmCall; use pallet_parachain_staking::Event as StakingEvent; @@ -558,6 +560,52 @@ fn candidate_request_is_pending_returns_false_for_non_existing_candidate() { }) } +#[test] +fn delegation_auto_compound_returns_value_if_set() { + ExtBuilder::default() + .with_balances(vec![(Alice, 1_000), (Charlie, 50)]) + .with_candidates(vec![(Alice, 1_000)]) + .with_auto_compounding_delegations(vec![(Charlie, Alice, 50, Percent::from_percent(50))]) + .build() + .execute_with(|| { + precompiles() + .prepare_test( + Alice, + Precompile, + PCall::delegation_auto_compound { + delegator: Address(Charlie.into()), + candidate: Address(Alice.into()), + }, + ) + .expect_cost(0) + .expect_no_logs() + .execute_returns_encoded(50u8); + }) +} + +#[test] +fn delegation_auto_compound_returns_zero_if_not_set() { + ExtBuilder::default() + .with_balances(vec![(Alice, 1_000), (Charlie, 50)]) + .with_candidates(vec![(Alice, 1_000)]) + .with_delegations(vec![(Charlie, Alice, 50)]) + .build() + .execute_with(|| { + precompiles() + .prepare_test( + Alice, + Precompile, + PCall::delegation_auto_compound { + delegator: Address(Charlie.into()), + candidate: Address(Alice.into()), + }, + ) + .expect_cost(0) + .expect_no_logs() + .execute_returns_encoded(0u8); + }) +} + #[test] fn join_candidates_works() { ExtBuilder::default() @@ -843,6 +891,7 @@ fn delegate_works() { delegator_position: pallet_parachain_staking::DelegatorAdded::AddedToTop { new_total: 2_000, }, + auto_compound: Percent::zero(), } .into(); // Assert that the events vector contains the one expected @@ -1156,6 +1205,100 @@ fn cancel_delegator_bonded_less_works() { }); } +#[test] +fn delegate_with_auto_compound_works() { + ExtBuilder::default() + .with_balances(vec![(Alice, 1_000), (Bob, 1_000)]) + .with_candidates(vec![(Alice, 1_000)]) + .build() + .execute_with(|| { + let input_data = PCall::delegate_with_auto_compound { + candidate: Address(Alice.into()), + amount: 1_000.into(), + auto_compound: 50, + candidate_delegation_count: 0.into(), + candidate_auto_compounding_delegation_count: 0.into(), + delegator_delegation_count: 0.into(), + } + .into(); + + // Make sure the call goes through successfully + assert_ok!(Call::Evm(evm_call(Bob, input_data)).dispatch(Origin::root())); + + assert!(ParachainStaking::is_delegator(&Bob)); + + let expected: crate::mock::Event = StakingEvent::Delegation { + delegator: Bob, + locked_amount: 1_000, + candidate: Alice, + delegator_position: pallet_parachain_staking::DelegatorAdded::AddedToTop { + new_total: 2_000, + }, + auto_compound: Percent::from_percent(50), + } + .into(); + // Assert that the events vector contains the one expected + assert!(events().contains(&expected)); + }); +} + +#[test] +fn set_auto_compound_works_if_delegation() { + ExtBuilder::default() + .with_balances(vec![(Alice, 1_000), (Bob, 1_000)]) + .with_candidates(vec![(Alice, 1_000)]) + .with_delegations(vec![(Bob, Alice, 1_000)]) + .build() + .execute_with(|| { + let input_data = PCall::set_auto_compound { + candidate: Address(Alice.into()), + value: 50, + candidate_auto_compounding_delegation_count: 0.into(), + delegator_delegation_count: 1.into(), + } + .into(); + + // Make sure the call goes through successfully + assert_ok!(Call::Evm(evm_call(Bob, input_data)).dispatch(Origin::root())); + + assert_eq!( + ParachainStaking::delegation_auto_compound(&Alice, &Bob), + Percent::from_percent(50) + ); + + let expected: crate::mock::Event = StakingEvent::AutoCompoundSet { + candidate: Alice, + delegator: Bob, + value: Percent::from_percent(50), + } + .into(); + // Assert that the events vector contains the one expected + assert!(events().contains(&expected)); + }); +} + +#[test] +fn set_auto_compound_fails_if_not_delegation() { + ExtBuilder::default() + .with_balances(vec![(Alice, 1000), (Bob, 1000)]) + .with_candidates(vec![(Alice, 1_000)]) + .build() + .execute_with(|| { + PrecompilesValue::get() + .prepare_test( + Alice, + Precompile, + PCall::set_auto_compound { + candidate: Address(Alice.into()), + value: 50, + candidate_auto_compounding_delegation_count: 0.into(), + delegator_delegation_count: 0.into(), + }, + ) + .execute_reverts(|output| from_utf8(&output).unwrap().contains("DelegatorDNE")); + }); +} + #[test] fn test_solidity_interface_has_all_function_selectors_documented_and_implemented() { for file in ["StakingInterface.sol"] { diff --git a/runtime/moonbase/tests/common/mod.rs b/runtime/moonbase/tests/common/mod.rs index cd7edcfdf8..1b60e11d98 100644 --- a/runtime/moonbase/tests/common/mod.rs +++ b/runtime/moonbase/tests/common/mod.rs @@ -117,7 +117,7 @@ pub struct ExtBuilder { // [collator, amount] collators: Vec<(AccountId, Balance)>, // [delegator, collator, nomination_amount] - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, // per-round inflation config inflation: InflationInfo, // AuthorId -> AccoutId mappings @@ -186,7 +186,10 @@ impl ExtBuilder { } pub fn with_delegations(mut self, delegations: Vec<(AccountId, AccountId, Balance)>) -> Self { - self.delegations = delegations; + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, Percent::zero())) + .collect(); self } diff --git a/runtime/moonbeam/tests/common/mod.rs b/runtime/moonbeam/tests/common/mod.rs index 1251b35db1..a099da5b0d 100644 --- a/runtime/moonbeam/tests/common/mod.rs +++ b/runtime/moonbeam/tests/common/mod.rs @@ -129,7 +129,7 @@ pub struct ExtBuilder { // [collator, amount] collators: Vec<(AccountId, Balance)>, // [delegator, collator, nomination_amount] - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, // per-round inflation config inflation: InflationInfo, // AuthorId -> AccoutId mappings @@ -198,7 +198,10 @@ impl ExtBuilder { } pub fn with_delegations(mut self, delegations: Vec<(AccountId, AccountId, Balance)>) -> Self { - self.delegations = delegations; + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, Percent::zero())) + .collect(); self } diff --git a/runtime/moonriver/tests/common/mod.rs b/runtime/moonriver/tests/common/mod.rs index 2280cf8892..9a68523ac4 100644 --- a/runtime/moonriver/tests/common/mod.rs +++ b/runtime/moonriver/tests/common/mod.rs @@ -129,7 +129,7 @@ pub struct ExtBuilder { // [collator, amount] collators: Vec<(AccountId, Balance)>, // [delegator, collator, nomination_amount] - delegations: Vec<(AccountId, AccountId, Balance)>, + delegations: Vec<(AccountId, AccountId, Balance, Percent)>, // per-round inflation config inflation: InflationInfo, // AuthorId -> AccoutId mappings @@ -198,7 +198,10 @@ impl ExtBuilder { } pub fn with_delegations(mut self, delegations: Vec<(AccountId, AccountId, Balance)>) -> Self { - self.delegations = delegations; + self.delegations = delegations + .into_iter() + .map(|d| (d.0, d.1, d.2, Percent::zero())) + .collect(); self } diff --git a/scripts/benchmarking.sh b/scripts/benchmarking.sh index a0e1861200..d9943cb95f 100755 --- a/scripts/benchmarking.sh +++ b/scripts/benchmarking.sh @@ -54,7 +54,6 @@ function bench { fi WASMTIME_BACKTRACE_DETAILS=1 ${BINARY} benchmark pallet \ - --chain dev \ --execution=wasm \ --wasm-execution=compiled \ --pallet "${1}" \ @@ -62,7 +61,6 @@ function bench { --steps "${STEPS}" \ --repeat "${REPEAT}" \ --template=./benchmarking/frame-weight-template.hbs \ - --record-proof \ --json-file raw.json \ --output "${OUTPUT}" } diff --git a/tests/tests/test-staking/test-delegate-with-auto-compound.ts b/tests/tests/test-staking/test-delegate-with-auto-compound.ts new file mode 100644 index 0000000000..e0f756edcd --- /dev/null +++ b/tests/tests/test-staking/test-delegate-with-auto-compound.ts @@ -0,0 +1,235 @@ +import "@polkadot/api-augment"; +import "@moonbeam-network/api-augment"; +import { expect } from "chai"; +import { MIN_GLMR_STAKING, MIN_GLMR_DELEGATOR } from "../../util/constants"; +import { describeDevMoonbeam } from "../../util/setup-dev-tests"; +import { alith, baltathar, charleth, ethan } from "../../util/accounts"; +import { expectOk } from "../../util/expect"; + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - bond less than min", (context) => { + it("should fail", async () => { + const minDelegatorStk = context.polkadotApi.consts.parachainStaking.minDelegatorStk; + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, minDelegatorStk.subn(10), 50, 0, 0, 0) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("DelegatorBondBelowMin"); + }); +}); + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - candidate not exists", (context) => { + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("CandidateDNE"); + }); +}); + +describeDevMoonbeam( + "Staking - Delegate With Auto-Compound - candidate not exists and self", + (context) => { + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(ethan.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("CandidateDNE"); + }); + } +); + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - already a candidate", (context) => { + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(alith) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("CandidateExists"); + }); +}); + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - already delegated", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan) + ) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 1) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("AlreadyDelegatedCandidate"); + }); +}); + +describeDevMoonbeam( + "Staking - Delegate With Auto-Compound - wrong candidate delegation hint", + (context) => { + before("setup candidates alith & baltathar, and delegators ethan & charleth", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(charleth), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 1) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("TooLowCandidateDelegationCountToDelegate"); + }); + } +); + +describeDevMoonbeam( + "Staking - Delegate With Auto-Compound - wrong candidate auto-compounding delegation hint", + (context) => { + before("setup candidates alith & baltathar, and delegators ethan & charleth", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 50, 0, 0) + .signAsync(charleth), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 1, 0, 1) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal( + "TooLowCandidateAutoCompoundingDelegationCountToDelegate" + ); + }); + } +); + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - wrong delegation hint", (context) => { + before("setup candidates alith & baltathar, and delegators ethan & charleth", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(charleth), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 1, 0, 0) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("TooLowDelegationCountToDelegate"); + }); +}); + +describeDevMoonbeam("Staking - Delegate With Auto-Compound - valid request", (context) => { + const numberToHex = (n: BigInt): string => `0x${n.toString(16).padStart(32, "0")}`; + + let events; + before("should delegate", async () => { + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan) + ); + expect(result.successful).to.be.true; + events = result.events; + }); + + it("should succeed", async () => { + const delegatorState = await context.polkadotApi.query.parachainStaking.delegatorState( + ethan.address + ); + const autoCompoundConfig = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + const delegationEvents = events.reduce((acc, event) => { + if (context.polkadotApi.events.parachainStaking.Delegation.is(event.event)) { + acc.push({ + account: event.event.data[0].toString(), + amount: event.event.data[1].toBigInt(), + autoCompound: event.event.data[4].toJSON(), + }); + } + return acc; + }, []); + + expect(delegationEvents).to.deep.equal([ + { + account: ethan.address, + amount: 1000000000000000000n, + autoCompound: 50, + }, + ]); + expect(delegatorState.unwrap().toJSON()).to.deep.equal({ + delegations: [ + { + amount: numberToHex(MIN_GLMR_DELEGATOR), + owner: alith.address, + }, + ], + id: ethan.address, + lessTotal: 0, + status: { active: null }, + total: numberToHex(MIN_GLMR_DELEGATOR), + }); + expect(autoCompoundConfig).to.deep.equal({ + delegator: ethan.address, + value: 50, + }); + }); +}); diff --git a/tests/tests/test-staking/test-rewards-auto-compound.ts b/tests/tests/test-staking/test-rewards-auto-compound.ts new file mode 100644 index 0000000000..828608670e --- /dev/null +++ b/tests/tests/test-staking/test-rewards-auto-compound.ts @@ -0,0 +1,462 @@ +import "@polkadot/api-augment"; +import "@moonbeam-network/api-augment"; +import { expect } from "chai"; +import { GLMR, MIN_GLMR_DELEGATOR, MIN_GLMR_STAKING } from "../../util/constants"; +import { describeDevMoonbeam, DevTestContext } from "../../util/setup-dev-tests"; +import { alith, baltathar, ethan, generateKeyringPair } from "../../util/accounts"; +import { expectOk } from "../../util/expect"; +import { jumpRounds } from "../../util/block"; +import { BN, BN_ZERO } from "@polkadot/util"; +import { Percent } from "../../util/common"; +import { FrameSystemEventRecord } from "@polkadot/types/lookup"; +import { KeyringPair } from "@polkadot/keyring/types"; + +describeDevMoonbeam("Staking - Rewards Auto-Compound - no auto-compound config", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should not compound reward and emit no event", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect(compoundedEvent, "delegator reward was erroneously compounded").to.be.undefined; + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - 0% auto-compound", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should not compound reward and emit no event", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect(compoundedEvent, "delegator reward was erroneously compounded").to.be.undefined; + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - 1% auto-compound", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 1, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should compound 1% reward", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect( + compoundedEvent.amount.toString(), + "delegator did not get 1% of their rewarded auto-compounded" + ).to.equal(new Percent(1).ofCeil(rewardedEvent.amount).toString()); + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - 50% auto-compound", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should compound 50% reward", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect( + compoundedEvent.amount.toString(), + "delegator did not get 50% of their rewarded auto-compounded" + ).to.equal(new Percent(50).ofCeil(rewardedEvent.amount).toString()); + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - 100% auto-compound", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should compound 100% reward", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect( + compoundedEvent.amount.toString(), + "delegator did not get 100% of their rewarded auto-compounded" + ).to.equal(rewardedEvent.amount.toString()); + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - no revoke requests", (context) => { + before("should delegate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should auto-compound full amount", async () => { + const rewardDelay = context.polkadotApi.consts.parachainStaking.rewardPaymentDelay; + const blockHash = await jumpRounds(context, rewardDelay.addn(1).toNumber()); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find(({ delegator }) => delegator === ethan.address); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect( + compoundedEvent.amount.toString(), + "delegator did not get 100% of their rewarded auto-compounded" + ).to.equal(rewardedEvent.amount.toString()); + }); +}); + +describeDevMoonbeam( + "Staking - Rewards Auto-Compound - scheduled revoke request after round snapshot", + (context) => { + before("should scheduleLeaveDelegators", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 0) + .signAsync(ethan), + ]) + ); + await jumpRounds( + context, + context.polkadotApi.consts.parachainStaking.rewardPaymentDelay.toNumber() + ); + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .scheduleRevokeDelegation(alith.address) + .signAsync(ethan) + ) + ); + }); + + it("should reward but not compound", async () => { + const blockHash = await jumpRounds(context, 1); + const events = await getRewardedAndCompoundedEvents(context, blockHash); + const rewardedEvent = events.rewarded.find(({ account }) => account === ethan.address); + const compoundedEvent = events.compounded.find( + ({ delegator }) => delegator === ethan.address + ); + + expect(rewardedEvent, "delegator was not rewarded").to.not.be.undefined; + expect(compoundedEvent, "delegator reward was erroneously auto-compounded").to.be.undefined; + }); + } +); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - delegator leave", (context) => { + before("should delegate and add baltathar as candidate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 0) + .signAsync(ethan), + ]) + ); + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 1) + .signAsync(ethan) + ) + ); + + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking.scheduleLeaveDelegators().signAsync(ethan) + ) + ); + + const roundDelay = context.polkadotApi.consts.parachainStaking.leaveDelegatorsDelay.toNumber(); + await jumpRounds(context, roundDelay); + }); + + it("should remove all auto-compound configs across multiple candidates", async () => { + const autoCompoundDelegationsAlithBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithBefore.toJSON()).to.not.be.empty; + expect(autoCompoundDelegationsBaltatharBefore.toJSON()).to.not.be.empty; + + await context.createBlock( + context.polkadotApi.tx.parachainStaking + .executeLeaveDelegators(ethan.address, 2) + .signAsync(ethan) + ); + + const autoCompoundDelegationsAlithAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithAfter.toJSON()).to.be.empty; + expect(autoCompoundDelegationsBaltatharAfter.toJSON()).to.be.empty; + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - candidate leave", (context) => { + before("should delegate and add baltathar as candidate", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 0) + .signAsync(ethan), + ]) + ); + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 100, 0, 0, 1) + .signAsync(ethan) + ) + ); + + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking.scheduleLeaveCandidates(2).signAsync(baltathar) + ) + ); + + const roundDelay = context.polkadotApi.consts.parachainStaking.leaveCandidatesDelay.toNumber(); + await jumpRounds(context, roundDelay); + }); + + it("should remove auto-compound config only for baltathar", async () => { + const autoCompoundDelegationsAlithBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithBefore.toJSON()).to.not.be.empty; + expect(autoCompoundDelegationsBaltatharBefore.toJSON()).to.not.be.empty; + + await context.createBlock( + context.polkadotApi.tx.parachainStaking + .executeLeaveCandidates(baltathar.address, 1) + .signAsync(ethan) + ); + + const autoCompoundDelegationsAlithAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithAfter.toJSON()).to.not.be.empty; + expect(autoCompoundDelegationsBaltatharAfter.toJSON()).to.be.empty; + }); +}); + +describeDevMoonbeam("Staking - Rewards Auto-Compound - bottom delegation kick", (context) => { + let newDelegator: KeyringPair; + let delegationCount = 0; + + before("should delegate and add baltathar as candidate", async () => { + const [delegator, ...otherDelegators] = new Array( + context.polkadotApi.consts.parachainStaking.maxTopDelegationsPerCandidate.toNumber() + + context.polkadotApi.consts.parachainStaking.maxBottomDelegationsPerCandidate.toNumber() + ) + .fill(0) + .map(() => generateKeyringPair()); + newDelegator = delegator; + + await expectOk( + context.createBlock([ + context.polkadotApi.tx.sudo + .sudo(context.polkadotApi.tx.parachainStaking.setBlocksPerRound(10)) + .signAsync(alith), + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR, delegationCount++, 0) + .signAsync(ethan), + ]) + ); + + let alithNonce = await context.web3.eth.getTransactionCount(alith.address); + await expectOk( + context.createBlock([ + context.polkadotApi.tx.balances + .transfer(newDelegator.address, MIN_GLMR_STAKING) + .signAsync(alith, { nonce: alithNonce++ }), + ...otherDelegators.map((d) => + context.polkadotApi.tx.balances + .transfer(d.address, MIN_GLMR_STAKING) + .signAsync(alith, { nonce: alithNonce++ }) + ), + ]) + ); + await expectOk( + context.createBlock([ + // delegate a lower amount from Ethan, so they are kicked when new delegation comes + context.polkadotApi.tx.parachainStaking + .delegate(baltathar.address, MIN_GLMR_DELEGATOR, 0, 1) + .signAsync(ethan), + ...otherDelegators.map((d) => + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR + 10n * GLMR, delegationCount++, 1) + .signAsync(d) + ), + ]) + ); + + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 100, 0, 2) + .signAsync(ethan) + ) + ); + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(baltathar.address, 100, 0, 2) + .signAsync(ethan) + ) + ); + }); + + it("should remove auto-compound config only for alith", async () => { + const autoCompoundDelegationsAlithBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharBefore = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithBefore.toJSON()).to.not.be.empty; + expect(autoCompoundDelegationsBaltatharBefore.toJSON()).to.not.be.empty; + + // This kicks ethan from bottom delegations for alith + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR + 10n * GLMR, delegationCount++, 0) + .signAsync(newDelegator) + ) + ); + + const autoCompoundDelegationsAlithAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations(alith.address); + const autoCompoundDelegationsBaltatharAfter = + await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + baltathar.address + ); + expect(autoCompoundDelegationsAlithAfter.toJSON()).to.be.empty; + expect(autoCompoundDelegationsBaltatharAfter.toJSON()).to.not.be.empty; + }); +}); + +async function getRewardedAndCompoundedEvents(context: DevTestContext, blockHash: string) { + return (await (await context.polkadotApi.at(blockHash)).query.system.events()).reduce( + (acc, event) => { + if (context.polkadotApi.events.parachainStaking.Rewarded.is(event.event)) { + acc.rewarded.push({ + account: event.event.data[0].toString(), + amount: event.event.data[1], + }); + } else if (context.polkadotApi.events.parachainStaking.Compounded.is(event.event)) { + acc.compounded.push({ + candidate: event.event.data[0].toString(), + delegator: event.event.data[1].toString(), + amount: event.event.data[2], + }); + } + return acc; + }, + { rewarded: [], compounded: [] } + ); +} diff --git a/tests/tests/test-staking/test-set-auto-compound.ts b/tests/tests/test-staking/test-set-auto-compound.ts new file mode 100644 index 0000000000..f01f1833f6 --- /dev/null +++ b/tests/tests/test-staking/test-set-auto-compound.ts @@ -0,0 +1,276 @@ +import "@polkadot/api-augment"; +import "@moonbeam-network/api-augment"; +import { expect } from "chai"; +import { MIN_GLMR_STAKING, MIN_GLMR_DELEGATOR } from "../../util/constants"; +import { describeDevMoonbeam } from "../../util/setup-dev-tests"; +import { alith, baltathar, charleth, ethan } from "../../util/accounts"; +import { expectOk } from "../../util/expect"; + +describeDevMoonbeam("Staking - Set Auto-Compound - delegator not exists", (context) => { + it("should fail", async () => { + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 50, 0, 0) + .signAsync(ethan) + ); + expect(result.successful).to.be.false; + expect(result.error.name).to.equal("DelegatorDNE"); + }); +}); + +describeDevMoonbeam("Staking - Set Auto-Compound - delegation not exists", (context) => { + before("setup delegation to alith", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should fail", async () => { + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(baltathar.address, 50, 0, 1) + .signAsync(ethan) + ); + expect(result.successful).to.be.false; + expect(result.error.name).to.equal("DelegationDNE"); + }); +}); + +describeDevMoonbeam("Staking - Set Auto-Compound - wrong delegation hint", (context) => { + before("setup candidates alith & baltathar, and delegators ethan & charleth", async () => { + await expectOk( + context.createBlock([ + context.polkadotApi.tx.parachainStaking + .joinCandidates(MIN_GLMR_STAKING, 1) + .signAsync(baltathar), + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(baltathar.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan), + ]) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 50, 0, 0) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal("TooLowDelegationCountToAutoCompound"); + }); +}); + +describeDevMoonbeam( + "Staking - Set Auto-Compound - \ + wrong candidate auto-compounding delegation hint", + (context) => { + before("setup candidates alith & baltathar, and delegators ethan & charleth", async () => { + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 50, 0, 0, 0) + .signAsync(ethan) + ) + ); + }); + + it("should fail", async () => { + const block = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 50, 0, 1) + .signAsync(ethan) + ); + expect(block.result.successful).to.be.false; + expect(block.result.error.name).to.equal( + "TooLowCandidateAutoCompoundingDelegationCountToAutoCompound" + ); + }); + } +); + +describeDevMoonbeam("Staking - Set Auto-Compound - insert new config", (context) => { + before("setup delegate", async () => { + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegate(alith.address, MIN_GLMR_DELEGATOR, 0, 0) + .signAsync(ethan) + ) + ); + }); + + it("should succeed", async () => { + const autoCompoundConfigBefore = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + expect(autoCompoundConfigBefore).to.be.undefined; + + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 50, 0, 1) + .signAsync(ethan) + ); + expect(result.successful).to.be.true; + + const autoCompoundConfigAfter = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + const delegationAutoCompoundEvents = result.events.reduce((acc, event) => { + if (context.polkadotApi.events.parachainStaking.AutoCompoundSet.is(event.event)) { + acc.push({ + candidate: event.event.data[0].toString(), + delegator: event.event.data[1].toString(), + value: event.event.data[2].toJSON(), + }); + } + return acc; + }, []); + + expect(delegationAutoCompoundEvents).to.deep.equal([ + { + candidate: alith.address, + delegator: ethan.address, + value: 50, + }, + ]); + expect(autoCompoundConfigAfter).to.deep.equal({ + delegator: ethan.address, + value: 50, + }); + }); +}); + +describeDevMoonbeam("Staking - Set Auto-Compound - update existing config", (context) => { + before("setup delegateWithAutoCompound", async () => { + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 10, 0, 0, 0) + .signAsync(ethan) + ) + ); + }); + + it("should succeed", async () => { + const autoCompoundConfigBefore = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + expect(autoCompoundConfigBefore).to.not.be.undefined; + expect(autoCompoundConfigBefore.value).to.equal(10); + + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 50, 1, 1) + .signAsync(ethan) + ); + expect(result.successful).to.be.true; + + const autoCompoundConfigAfter = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + const delegationAutoCompoundEvents = result.events.reduce((acc, event) => { + if (context.polkadotApi.events.parachainStaking.AutoCompoundSet.is(event.event)) { + acc.push({ + candidate: event.event.data[0].toString(), + delegator: event.event.data[1].toString(), + value: event.event.data[2].toJSON(), + }); + } + return acc; + }, []); + + expect(delegationAutoCompoundEvents).to.deep.equal([ + { + candidate: alith.address, + delegator: ethan.address, + value: 50, + }, + ]); + expect(autoCompoundConfigAfter).to.deep.equal({ + delegator: ethan.address, + value: 50, + }); + }); +}); + +describeDevMoonbeam( + "Staking - Set Auto-Compound - remove existing config if 0% auto-compound", + (context) => { + before("setup delegateWithAutoCompound", async () => { + await expectOk( + context.createBlock( + context.polkadotApi.tx.parachainStaking + .delegateWithAutoCompound(alith.address, MIN_GLMR_DELEGATOR, 10, 0, 0, 0) + .signAsync(ethan) + ) + ); + }); + + it("should succeed", async () => { + const autoCompoundConfigBefore = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + expect(autoCompoundConfigBefore).to.not.be.undefined; + expect(autoCompoundConfigBefore.value).to.equal(10); + + const { result } = await context.createBlock( + context.polkadotApi.tx.parachainStaking + .setAutoCompound(alith.address, 0, 1, 1) + .signAsync(ethan) + ); + expect(result.successful).to.be.true; + + const autoCompoundConfigAfter = ( + (await context.polkadotApi.query.parachainStaking.autoCompoundingDelegations( + alith.address + )) as any + ) + .toJSON() + .find((d) => d.delegator === ethan.address); + const delegationAutoCompoundEvents = result.events.reduce((acc, event) => { + if (context.polkadotApi.events.parachainStaking.AutoCompoundSet.is(event.event)) { + acc.push({ + candidate: event.event.data[0].toString(), + delegator: event.event.data[1].toString(), + value: event.event.data[2].toJSON(), + }); + } + return acc; + }, []); + + expect(delegationAutoCompoundEvents).to.deep.equal([ + { + candidate: alith.address, + delegator: ethan.address, + value: 0, + }, + ]); + expect(autoCompoundConfigAfter).to.be.undefined; + }); + } +); diff --git a/tests/util/common.ts b/tests/util/common.ts index 24fabcfbb5..72636841fe 100644 --- a/tests/util/common.ts +++ b/tests/util/common.ts @@ -1,3 +1,5 @@ +import { BN } from "@polkadot/util"; + // Sort dict by key export function sortObjectByKeys(o) { return Object.keys(o) @@ -5,6 +7,87 @@ export function sortObjectByKeys(o) { .reduce((r, k) => ((r[k] = o[k]), r), {}); } +// Perthings arithmetic conformant type. +class Perthing { + private unit: BN; + private perthing: BN; + + constructor(unit: BN, numerator: BN | number, denominator?: BN | number) { + if (!(numerator instanceof BN)) { + numerator = new BN(numerator.toString()); + } + if (denominator && !(denominator instanceof BN)) { + denominator = new BN(denominator.toString()); + } + + this.unit = unit; + if (denominator) { + this.perthing = numerator.mul(unit).div(denominator as BN); + } else { + this.perthing = numerator; + } + } + + value(): BN { + return this.perthing; + } + + of(value: BN): BN { + return this.divNearest(this.perthing.mul(value), this.unit); + } + + ofCeil(value: BN): BN { + return this.divCeil(this.perthing.mul(value), this.unit); + } + + toString(): string { + return `${this.perthing.toString()}`; + } + + divCeil(a: any, num: BN) { + var dm = a.divmod(num); + + // Fast case - exact division + if (dm.mod.isZero()) return dm.div; + + // Round up + return dm.div.negative !== 0 ? dm.div.isubn(1) : dm.div.iaddn(1); + } + + divNearest(a: any, num: BN) { + var dm = a.divmod(num); + + // Fast case - exact division + if (dm.mod.isZero()) return dm.div; + + var mod = dm.div.negative !== 0 ? dm.mod.isub(num) : dm.mod; + + var half = num.ushrn(1); + var r2 = num.andln(1) as any; + var cmp = mod.cmp(half); + + // Round down + if (cmp <= 0 || (r2 === 1 && cmp === 0)) return dm.div; + + // Round up + return dm.div.negative !== 0 ? dm.div.isubn(1) : dm.div.iaddn(1); + } +} + +// Perthings arithmetic conformant type representing part(s) per billion. +export class Perbill extends Perthing { + constructor(numerator: BN | number, denominator?: BN | number) { + super(new BN(1_000_000_000), numerator, denominator); + } +} + +// Perthings arithmetic conformant type representing part(s) per cent. +export class Percent extends Perthing { + constructor(numerator: BN | number, denominator?: BN | number) { + super(new BN(100), numerator, denominator); + } +} + export function getObjectMethods(obj) { let properties = new Set(); let currentObj = obj; diff --git a/tools/pov/README.md b/tools/pov/README.md index d1f426c3b0..9cf03f6730 100644 --- a/tools/pov/README.md +++ b/tools/pov/README.md @@ -52,3 +52,11 @@ The above command overrides the parameters `x` and `y` and creates three benchma The original compiled ranges (e.g. `x in 1 .. 100`) are completely ignored, and substituted with the exact command-line parameters defined via `--params` (e.g. `x = 10 50 100`). 3. The execution above will create a `output.json` containing information for the above scenarios and would also open a chart-wise comparison in the browser. + +4. Analyze multiple results (optional) + +``` +ts-node tools/pov/index.ts analyze --input output-1.json output-2.json output-3.json +``` + +The above command will chart out all the input data on the same chart for better comparisons. diff --git a/tools/pov/index.ts b/tools/pov/index.ts index 2e00f02413..264533c06f 100755 --- a/tools/pov/index.ts +++ b/tools/pov/index.ts @@ -10,10 +10,35 @@ import { strict as assert } from "node:assert"; const exec = util.promisify(execProcess); +const openCmd = (() => { + switch (process.platform) { + case "darwin": + return "open"; + case "win32": + return "start"; + default: + return "xdg-open"; + } +})(); + async function main() { const argv = yargs(process.argv.slice(2)) .usage("Usage: $0") .version("1.0.0") + .command("analyze", "analyze multiple PoV analysis", (yargs) => { + yargs + .option("input", { + type: "string", + describe: "Input JSON files", + array: true, + }) + .option("output", { + type: "string", + default: "output-analyze.html", + describe: "The output HTML file", + }) + .demandOption(["input"]); + }) .command("view", "view a PoV analysis", (yargs) => { yargs .option("input", { @@ -82,6 +107,9 @@ async function main() { case "view": await view(argv.input, argv.output, argv.open); break; + case "analyze": + await analyze(argv.input, argv.output); + break; } return; @@ -420,20 +448,236 @@ async function view(input: string, output: string, open: boolean) { ); // editorconfig-checker-enable - const openCmd = (() => { - switch (process.platform) { - case "darwin": - return "open"; - case "win32": - return "start"; - default: - return "xdg-open"; - } - })(); - if (open) { await exec(`${openCmd} ${output}`); } } +async function analyze(inputs: string[], output: string) { + const dataMultiple = inputs.map((input) => JSON.parse(fs.readFileSync(input).toString("utf-8"))); + + const labels = dataMultiple[0].map((x: any) => x["parameters"].join(",")); + const proofSizeMultiple = dataMultiple.map((data: any) => data.map((x: any) => x["proofSize"])); + const totalReadsMultiple = dataMultiple.map((data: any) => data.map((x: any) => x["totalReads"])); + const totalWritesMultiple = dataMultiple.map((data: any) => + data.map((x: any) => x["totalWrites"]) + ); + const extrinsicTimeMultiple = dataMultiple.map((data: any) => + data.map((x: any) => x["extrinsicTime"]) + ); + + function random_rgb() { + const o = Math.round; + const r = Math.random; + const s = 255; + return `rgba(${o(r() * s)},${o(r() * s)},${o(r() * s)},1.0)`; + } + const colors = new Array(inputs.length).fill(0).map((x) => random_rgb()); + + // editorconfig-checker-disable + fs.writeFileSync( + output, + ` + + + + + +
+ +
+
+ +
+
+ +
+ + + ` + ); + // editorconfig-checker-enable + + await exec(`${openCmd} ${output}`); +} + main().catch((err) => console.error(`ERR! ${err}`));