diff --git a/frame/nomination-pools/src/lib.rs b/frame/nomination-pools/src/lib.rs index 3cb8abedda2fb..d011cd551a82e 100644 --- a/frame/nomination-pools/src/lib.rs +++ b/frame/nomination-pools/src/lib.rs @@ -469,6 +469,16 @@ impl PoolMember { } } + /// Total balance of the member. + #[cfg(any(feature = "try-runtime", test))] + fn total_balance(&self) -> BalanceOf { + // TODO + self.active_balance().saturating_add(self.unbonding_eras + .as_ref() + .iter() + .fold(BalanceOf::::zero(), |acc, (_, v)| acc.saturating_add(*v))) + } + /// Total points of this member, both active and unbonding. fn total_points(&self) -> BalanceOf { self.active_points().saturating_add(self.unbonding_points()) @@ -684,6 +694,9 @@ impl BondedPool { fn issue(&mut self, new_funds: BalanceOf) -> BalanceOf { let points_to_issue = self.balance_to_point(new_funds); self.points = self.points.saturating_add(points_to_issue); + TotalValueLocked::::mutate(|tvl| { + tvl.saturating_accrue(new_funds); + }); points_to_issue } @@ -900,9 +913,8 @@ impl BondedPool { /// Bond exactly `amount` from `who`'s funds into this pool. /// - /// If the bond type is `Create`, `Staking::bond` is called, and `who` - /// is allowed to be killed. Otherwise, `Staking::bond_extra` is called and `who` - /// cannot be killed. + /// If the bond type is `Create`, `Staking::bond` is called, and `who` is allowed to be killed. + /// Otherwise, `Staking::bond_extra` is called and `who` cannot be killed. /// /// Returns `Ok(points_issues)`, `Err` otherwise. fn try_bond_funds( @@ -1097,6 +1109,10 @@ impl UnbondPool { self.points = self.points.saturating_sub(points); self.balance = self.balance.saturating_sub(balance_to_unbond); + TotalValueLocked::::mutate(|tvl| { + tvl.saturating_reduce(balance_to_unbond); + }); + balance_to_unbond } } @@ -1247,6 +1263,10 @@ pub mod pallet { type MaxUnbonding: Get; } + /// Summary of all pools + #[pallet::storage] + pub type TotalValueLocked = StorageValue<_, BalanceOf, ValueQuery>; + /// Minimum amount to bond to join a pool. #[pallet::storage] pub type MinJoinBond = StorageValue<_, BalanceOf, ValueQuery>; @@ -1439,9 +1459,9 @@ pub mod pallet { CannotWithdrawAny, /// The amount does not meet the minimum bond to either join or create a pool. /// - /// The depositor can never unbond to a value less than - /// `Pallet::depositor_min_bond`. The caller does not have nominating - /// permissions for the pool. Members can never unbond to a value below `MinJoinBond`. + /// The depositor can never unbond to a value less than `Pallet::depositor_min_bond`. The + /// caller does not have nominating permissions for the pool. Members can never unbond to a + /// value below `MinJoinBond`. MinimumBondNotMet, /// The transaction could not be executed due to overflow risk for the pool. OverflowRisk, @@ -2464,6 +2484,7 @@ impl Pallet { let bonded_pools = BondedPools::::iter_keys().collect::>(); let reward_pools = RewardPools::::iter_keys().collect::>(); assert_eq!(bonded_pools, reward_pools); + let mut expected_tvl = Zero::zero(); assert!(Metadata::::iter_keys().all(|k| bonded_pools.contains(&k))); assert!(SubPoolsStorage::::iter_keys().all(|k| bonded_pools.contains(&k))); @@ -2493,6 +2514,7 @@ impl Pallet { assert!(!d.total_points().is_zero(), "no member should have zero points: {:?}", d); *pools_members.entry(d.pool_id).or_default() += 1; all_members += 1; + expected_tvl += d.tota let reward_pool = RewardPools::::get(d.pool_id).unwrap(); if !bonded_pool.points.is_zero() { @@ -2535,8 +2557,11 @@ impl Pallet { "depositor must always have MinCreateBond stake in the pool, except for when the \ pool is being destroyed and the depositor is the last member", ); + expected_tvl += T::Staking::total_stake(&bonded_pool.bonded_account()) + .expect("all pools must have total stake"); }); assert!(MaxPoolMembers::::get().map_or(true, |max| all_members <= max)); + assert_eq!(TotalValueLocked::::get(), expected_tvl); if level <= 1 { return Ok(()) @@ -2586,12 +2611,21 @@ impl OnStakerSlash> for Pallet { // anything here. slashed_bonded: BalanceOf, slashed_unlocking: &BTreeMap>, + total_slashed: BalanceOf, ) { - if let Some(pool_id) = ReversePoolIdLookup::::get(pool_account) { - let mut sub_pools = match SubPoolsStorage::::get(pool_id).defensive() { + if let Some(pool_id) = ReversePoolIdLookup::::get(pool_account).defensive() { + // TODO: bug here: a slash toward a pool who's subpools were missing would not be tracked. write a test for it. + Self::deposit_event(Event::::PoolSlashed { pool_id, balance: slashed_bonded }); + + TotalValueLocked::::mutate(|tvl| { + tvl.saturating_reduce(total_slashed); + }); + + let mut sub_pools = match SubPoolsStorage::::get(pool_id) { Some(sub_pools) => sub_pools, None => return, }; + for (era, slashed_balance) in slashed_unlocking.iter() { if let Some(pool) = sub_pools.with_era.get_mut(era) { pool.balance = *slashed_balance; @@ -2602,8 +2636,6 @@ impl OnStakerSlash> for Pallet { }); } } - - Self::deposit_event(Event::::PoolSlashed { pool_id, balance: slashed_bonded }); SubPoolsStorage::::insert(pool_id, sub_pools); } } diff --git a/frame/nomination-pools/src/mock.rs b/frame/nomination-pools/src/mock.rs index 99d521df3241b..c44000f9ea262 100644 --- a/frame/nomination-pools/src/mock.rs +++ b/frame/nomination-pools/src/mock.rs @@ -28,7 +28,8 @@ parameter_types! { pub static CurrentEra: EraIndex = 0; pub static BondingDuration: EraIndex = 3; pub storage BondedBalanceMap: BTreeMap = Default::default(); - pub storage UnbondingBalanceMap: BTreeMap = Default::default(); + // mao from user, to a vec of era to amount being unlocked in that era. + pub storage UnbondingBalanceMap: BTreeMap> = Default::default(); #[derive(Clone, PartialEq)] pub static MaxUnbonding: u32 = 8; pub static StakingMinBond: Balance = 10; @@ -42,6 +43,14 @@ impl StakingMock { x.insert(who, bonded); BondedBalanceMap::set(&x) } + + pub(crate) fn slash_to(pool_id: PoolId, amount: Balance) { + let acc = Pools::create_bonded_account(pool_id); + let bonded = BondedBalanceMap::get(); + let pre_total = bonded.get(&acc).unwrap(); + Self::set_bonded_balance(acc, pre_total - amount); + Pools::on_slash(&acc, pre_total - amount, &Default::default(), amount); + } } impl sp_staking::StakingInterface for StakingMock { @@ -78,8 +87,11 @@ impl sp_staking::StakingInterface for StakingMock { let mut x = BondedBalanceMap::get(); *x.get_mut(who).unwrap() = x.get_mut(who).unwrap().saturating_sub(amount); BondedBalanceMap::set(&x); + + let era = Self::current_era(); + let unlocking_at = era + Self::bonding_duration(); let mut y = UnbondingBalanceMap::get(); - *y.entry(*who).or_insert(Self::Balance::zero()) += amount; + y.entry(*who).or_insert(Default::default()).push((unlocking_at, amount)); UnbondingBalanceMap::set(&y); Ok(()) } @@ -89,11 +101,13 @@ impl sp_staking::StakingInterface for StakingMock { } fn withdraw_unbonded(who: Self::AccountId, _: u32) -> Result { - // Simulates removing unlocking chunks and only having the bonded balance locked - let mut x = UnbondingBalanceMap::get(); - x.remove(&who); - UnbondingBalanceMap::set(&x); + let mut unbonding_map = UnbondingBalanceMap::get(); + let staker_map = unbonding_map.get_mut(&who).ok_or("not a staker")?; + + let current_era = Self::current_era(); + staker_map.retain(|(unlocking_at, _amount)| *unlocking_at > current_era); + UnbondingBalanceMap::set(&unbonding_map); Ok(UnbondingBalanceMap::get().is_empty() && BondedBalanceMap::get().is_empty()) } @@ -118,13 +132,15 @@ impl sp_staking::StakingInterface for StakingMock { fn stake(who: &Self::AccountId) -> Result, DispatchError> { match ( - UnbondingBalanceMap::get().get(who).map(|v| *v), - BondedBalanceMap::get().get(who).map(|v| *v), + UnbondingBalanceMap::get().get(who).cloned(), + BondedBalanceMap::get().get(who).cloned(), ) { - (None, None) => Err(DispatchError::Other("balance not found")), - (Some(v), None) => Ok(Stake { total: v, active: 0, stash: *who }), + (None, None) => Err(DispatchError::Other("stake not found")), + (Some(m), None) => + Ok(Stake { total: m.into_iter().map(|(_, v)| v).sum(), active: 0, stash: *who }), (None, Some(v)) => Ok(Stake { total: v, active: v, stash: *who }), - (Some(a), Some(b)) => Ok(Stake { total: a + b, active: b, stash: *who }), + (Some(m), Some(v)) => + Ok(Stake { total: v + m.into_iter().map(|(_, v)| v).sum::(), active: v, stash: *who }), } } diff --git a/frame/nomination-pools/src/tests.rs b/frame/nomination-pools/src/tests.rs index 7d5d418bbf2c8..76fb842b8311d 100644 --- a/frame/nomination-pools/src/tests.rs +++ b/frame/nomination-pools/src/tests.rs @@ -49,6 +49,9 @@ fn test_setup_works() { assert_eq!(StakingMock::bonding_duration(), 3); assert!(Metadata::::contains_key(1)); + // initial member. + assert_eq!(TotalValueLocked::::get(), 10); + let last_pool = LastPoolId::::get(); assert_eq!( BondedPool::::get(last_pool).unwrap(), @@ -440,12 +443,12 @@ mod join { // Given Balances::make_free_balance_be(&11, ExistentialDeposit::get() + 2); assert!(!PoolMembers::::contains_key(&11)); + assert_eq!(TotalValueLocked::::get(), 10); // When assert_ok!(Pools::join(RuntimeOrigin::signed(11), 2, 1)); // Then - assert_eq!( pool_events_since_last_call(), vec![ @@ -454,6 +457,7 @@ mod join { Event::Bonded { member: 11, pool_id: 1, bonded: 2, joined: true }, ] ); + assert_eq!(TotalValueLocked::::get(), 12); assert_eq!( PoolMembers::::get(&11).unwrap(), @@ -477,6 +481,7 @@ mod join { pool_events_since_last_call(), vec![Event::Bonded { member: 12, pool_id: 1, bonded: 12, joined: true }] ); + assert_eq!(TotalValueLocked::::get(), 24); assert_eq!( PoolMembers::::get(&12).unwrap(), @@ -2079,11 +2084,15 @@ mod unbond { .min_join_bond(10) .add_members(vec![(20, 20)]) .build_and_execute(|| { + assert_eq!(TotalValueLocked::::get(), 30); // can unbond to above limit assert_ok!(Pools::unbond(RuntimeOrigin::signed(20), 20, 5)); assert_eq!(PoolMembers::::get(20).unwrap().active_points(), 15); assert_eq!(PoolMembers::::get(20).unwrap().unbonding_points(), 5); + // tvl remains unchanged. + assert_eq!(TotalValueLocked::::get(), 30); + // cannot go to below 10: assert_noop!( Pools::unbond(RuntimeOrigin::signed(20), 20, 10), @@ -2377,8 +2386,8 @@ mod unbond { .add_members(vec![(40, 40), (550, 550)]) .build_and_execute(|| { let ed = Balances::minimum_balance(); - // Given a slash from 600 -> 100 - StakingMock::set_bonded_balance(default_bonded_account(), 100); + // Given a slash from 600 -> 500 + StakingMock::slash_to(1, 500); // and unclaimed rewards of 600. Balances::make_free_balance_be(&default_reward_account(), ed + 600); @@ -2409,8 +2418,9 @@ mod unbond { Event::Bonded { member: 10, pool_id: 1, bonded: 10, joined: true }, Event::Bonded { member: 40, pool_id: 1, bonded: 40, joined: true }, Event::Bonded { member: 550, pool_id: 1, bonded: 550, joined: true }, + Event::PoolSlashed { pool_id: 1, balance: 100 }, Event::PaidOut { member: 40, pool_id: 1, payout: 40 }, - Event::Unbonded { member: 40, pool_id: 1, points: 6, balance: 6, era: 3 } + Event::Unbonded { member: 40, pool_id: 1, balance: 6, points: 6, era: 3 } ] ); @@ -2625,7 +2635,7 @@ mod unbond { ); assert_eq!( *UnbondingBalanceMap::get().get(&default_bonded_account()).unwrap(), - 100 + 200 + vec![(3, 200)], ); }); } @@ -2724,7 +2734,7 @@ mod unbond { } ); assert_eq!(StakingMock::active_stake(&default_bonded_account()).unwrap(), 0); - assert_eq!(*UnbondingBalanceMap::get().get(&default_bonded_account()).unwrap(), 10); + assert_eq!(*UnbondingBalanceMap::get().get(&default_bonded_account()).unwrap(), vec![(6, 10)]); }); } @@ -3001,7 +3011,7 @@ mod unbond { assert_eq!(PoolMembers::::get(10).unwrap().unbonding_points(), 0); // slash the default pool - StakingMock::set_bonded_balance(Pools::create_bonded_account(1), 5); + StakingMock::slash_to(1, 5); // cannot unbond even 7, because the value of shares is now less. assert_noop!( @@ -3080,21 +3090,23 @@ mod pool_withdraw_unbonded { #[test] fn pool_withdraw_unbonded_works() { - ExtBuilder::default().build_and_execute(|| { + ExtBuilder::default().add_members(vec![(20, 10)]).build_and_execute(|| { // Given 10 unbond'ed directly against the pool account - assert_ok!(StakingMock::unbond(&default_bonded_account(), 5)); - // and the pool account only has 10 balance - assert_eq!(StakingMock::active_stake(&default_bonded_account()), Ok(5)); - assert_eq!(StakingMock::total_stake(&default_bonded_account()), Ok(10)); - assert_eq!(Balances::free_balance(&default_bonded_account()), 10); + + assert_ok!(Pools::unbond(RuntimeOrigin::signed(20), 20, 5)); + + assert_eq!(StakingMock::active_stake(&default_bonded_account()), Ok(15)); + assert_eq!(StakingMock::total_stake(&default_bonded_account()), Ok(20)); + assert_eq!(Balances::free_balance(&default_bonded_account()), 20); // When + CurrentEra::set(StakingMock::current_era() + StakingMock::bonding_duration() + 1); assert_ok!(Pools::pool_withdraw_unbonded(RuntimeOrigin::signed(10), 1, 0)); // Then there unbonding balance is no longer locked - assert_eq!(StakingMock::active_stake(&default_bonded_account()), Ok(5)); - assert_eq!(StakingMock::total_stake(&default_bonded_account()), Ok(5)); - assert_eq!(Balances::free_balance(&default_bonded_account()), 10); + assert_eq!(StakingMock::active_stake(&default_bonded_account()), Ok(15)); + assert_eq!(StakingMock::total_stake(&default_bonded_account()), Ok(15)); + assert_eq!(Balances::free_balance(&default_bonded_account()), 20); }); } } @@ -3131,8 +3143,9 @@ mod withdraw_unbonded { unbond_pool.balance /= 2; // 295 SubPoolsStorage::::insert(1, sub_pools); // Update the equivalent of the unbonding chunks for the `StakingMock` - let mut x = UnbondingBalanceMap::get(); - *x.get_mut(&default_bonded_account()).unwrap() /= 5; + let x = UnbondingBalanceMap::get(); + // TODO: + // *x.get_mut(&default_bonded_account()).unwrap() /= 5; UnbondingBalanceMap::set(&x); Balances::make_free_balance_be( &default_bonded_account(), @@ -3767,6 +3780,7 @@ mod withdraw_unbonded { #[test] fn full_multi_step_withdrawing_non_depositor() { ExtBuilder::default().add_members(vec![(100, 100)]).build_and_execute(|| { + assert_eq!(TotalValueLocked::::get(), 110); // given assert_ok!(Pools::unbond(RuntimeOrigin::signed(100), 100, 75)); assert_eq!( @@ -3774,6 +3788,9 @@ mod withdraw_unbonded { member_unbonding_eras!(3 => 75) ); + // tvl unchanged. + assert_eq!(TotalValueLocked::::get(), 110); + // progress one era and unbond the leftover. CurrentEra::set(1); assert_ok!(Pools::unbond(RuntimeOrigin::signed(100), 100, 25)); @@ -3786,6 +3803,8 @@ mod withdraw_unbonded { Pools::withdraw_unbonded(RuntimeOrigin::signed(100), 100, 0), Error::::CannotWithdrawAny ); + // tvl unchanged. + assert_eq!(TotalValueLocked::::get(), 110); // now the 75 should be free. CurrentEra::set(3); @@ -3805,6 +3824,8 @@ mod withdraw_unbonded { PoolMembers::::get(100).unwrap().unbonding_eras, member_unbonding_eras!(4 => 25) ); + // tvl updated + assert_eq!(TotalValueLocked::::get(), 35); // the 25 should be free now, and the member removed. CurrentEra::set(4); @@ -4070,12 +4091,13 @@ mod create { let next_pool_stash = Pools::create_bonded_account(2); let ed = Balances::minimum_balance(); + assert_eq!(TotalValueLocked::::get(), 10); assert!(!BondedPools::::contains_key(2)); assert!(!RewardPools::::contains_key(2)); assert!(!PoolMembers::::contains_key(11)); assert_err!( StakingMock::active_stake(&next_pool_stash), - DispatchError::Other("balance not found") + DispatchError::Other("stake not found") ); Balances::make_free_balance_be(&11, StakingMock::minimum_nominator_bond() + ed); @@ -4086,6 +4108,7 @@ mod create { 456, 789 )); + assert_eq!(TotalValueLocked::::get(), 10 + StakingMock::minimum_nominator_bond()); assert_eq!(Balances::free_balance(&11), 0); assert_eq!( @@ -4549,12 +4572,14 @@ mod bond_extra { assert_eq!(BondedPools::::get(1).unwrap().points, 30); assert_eq!(Balances::free_balance(10), 35); assert_eq!(Balances::free_balance(20), 20); + assert_eq!(TotalValueLocked::::get(), 30); // when assert_ok!(Pools::bond_extra(RuntimeOrigin::signed(10), BondExtra::Rewards)); // then assert_eq!(Balances::free_balance(10), 35); + assert_eq!(TotalValueLocked::::get(), 31); // 10's share of the reward is 1/3, since they gave 10/30 of the total shares. assert_eq!(PoolMembers::::get(10).unwrap().points, 10 + 1); assert_eq!(BondedPools::::get(1).unwrap().points, 30 + 1); @@ -4564,6 +4589,7 @@ mod bond_extra { // then assert_eq!(Balances::free_balance(20), 20); + assert_eq!(TotalValueLocked::::get(), 33); // 20's share of the rewards is the other 2/3 of the rewards, since they have 20/30 of // the shares assert_eq!(PoolMembers::::get(20).unwrap().points, 20 + 2); @@ -4951,7 +4977,7 @@ mod reward_counter_precision { ); // slash this pool by 99% of that. - StakingMock::set_bonded_balance(default_bonded_account(), DOT + pool_bond / 100); + StakingMock::slash_to(1, pool_bond * 99 / 100); // some whale now joins with the other half ot the total issuance. This will trigger an // overflow. This test is actually a bit too lenient because all the reward counters are diff --git a/frame/root-offences/src/mock.rs b/frame/root-offences/src/mock.rs index 273fbf614169d..e0b2eb7480461 100644 --- a/frame/root-offences/src/mock.rs +++ b/frame/root-offences/src/mock.rs @@ -145,17 +145,6 @@ impl onchain::Config for OnChainSeqPhragmen { type TargetsBound = ConstU32<{ u32::MAX }>; } -pub struct OnStakerSlashMock(core::marker::PhantomData); -impl sp_staking::OnStakerSlash for OnStakerSlashMock { - fn on_slash( - _pool_account: &AccountId, - slashed_bonded: Balance, - slashed_chunks: &BTreeMap, - ) { - LedgerSlashPerEra::set((slashed_bonded, slashed_chunks.clone())); - } -} - parameter_types! { pub const RewardCurve: &'static PiecewiseLinear<'static> = &REWARD_CURVE; pub static Offset: BlockNumber = 0; @@ -163,7 +152,6 @@ parameter_types! { pub static SessionsPerEra: SessionIndex = 3; pub static SlashDeferDuration: EraIndex = 0; pub const BondingDuration: EraIndex = 3; - pub static LedgerSlashPerEra: (BalanceOf, BTreeMap>) = (Zero::zero(), BTreeMap::new()); pub const OffendingValidatorsThreshold: Perbill = Perbill::from_percent(75); } @@ -192,7 +180,7 @@ impl pallet_staking::Config for Test { type MaxUnlockingChunks = ConstU32<32>; type HistoryDepth = ConstU32<84>; type VoterList = pallet_staking::UseNominatorsAndValidatorsMap; - type OnStakerSlash = OnStakerSlashMock; + type OnStakerSlash = (); type BenchmarkingConfig = pallet_staking::TestBenchmarkingConfig; type WeightInfo = (); } diff --git a/frame/staking/src/lib.rs b/frame/staking/src/lib.rs index 3672056534b75..dfc97ad4a3c2c 100644 --- a/frame/staking/src/lib.rs +++ b/frame/staking/src/lib.rs @@ -678,8 +678,9 @@ impl StakingLedger { // clean unlocking chunks that are set to zero. self.unlocking.retain(|c| !c.value.is_zero()); - T::OnStakerSlash::on_slash(&self.stash, self.active, &slashed_unlocking); - pre_slash_total.saturating_sub(self.total) + let total_slashed = pre_slash_total.saturating_sub(self.total); + T::OnStakerSlash::on_slash(&self.stash, self.active, &slashed_unlocking, total_slashed); + total_slashed } } diff --git a/primitives/staking/src/lib.rs b/primitives/staking/src/lib.rs index 9eb4a4890cdf8..ba36cf3cd7587 100644 --- a/primitives/staking/src/lib.rs +++ b/primitives/staking/src/lib.rs @@ -42,15 +42,17 @@ pub trait OnStakerSlash { /// * `slashed_active` - The new bonded balance of the staker after the slash was applied. /// * `slashed_unlocking` - A map of slashed eras, and the balance of that unlocking chunk after /// the slash is applied. Any era not present in the map is not affected at all. + /// * `total_slashed` - The total amount that was slashed. fn on_slash( stash: &AccountId, slashed_active: Balance, slashed_unlocking: &BTreeMap, + total_slashed: Balance, ); } impl OnStakerSlash for () { - fn on_slash(_: &AccountId, _: Balance, _: &BTreeMap) { + fn on_slash(_: &AccountId, _: Balance, _: &BTreeMap, _: Balance) { // Nothing to do here } }