Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes TotalValueLocked out of sync in nomination pools #3052

Merged
merged 18 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion polkadot/runtime/westend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1647,7 +1647,7 @@ pub mod migrations {
pallet_referenda::migration::v1::MigrateV0ToV1<Runtime, ()>,
pallet_grandpa::migrations::MigrateV4ToV5<Runtime>,
parachains_configuration::migration::v10::MigrateToV10<Runtime>,
pallet_nomination_pools::migration::versioned::V7ToV8<Runtime>,
pallet_nomination_pools::migration::versioned::V8ToV9<Runtime>,
UpgradeSessionKeys,
frame_support::migrations::RemovePallet<
ImOnlinePalletName,
Expand Down
15 changes: 15 additions & 0 deletions prdoc/pr_3052.prdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
title: "Fixes a scenario where a nomination pool's `TotalValueLocked` is out of sync due to staking's implicit withdraw"

doc:
- audience: Runtime Dev
description: |
The nomination pools pallet `TotalValueLocked` may get out of sync if the staking pallet
does implicit withdrawal of unlocking chunks belonging to a bonded pool stash. This fix
is based on a new method on the `OnStakingUpdate` traits, `on_withdraw`, which allows the
nomination pools pallet to adjust the `TotalValueLocked` every time there is an implicit or
explicit withdrawal from a bonded pool's stash.

crates:
- name: "pallet-nomination-pools"
- name: "pallet-staking"
- name: "sp-staking"
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,23 @@ frame-election-provider-support = { path = "../../election-provider-support" }

pallet-election-provider-multi-phase = { path = ".." }
pallet-staking = { path = "../../staking" }
pallet-nomination-pools = { path = "../../nomination-pools" }
pallet-bags-list = { path = "../../bags-list" }
pallet-balances = { path = "../../balances" }
pallet-timestamp = { path = "../../timestamp" }
pallet-session = { path = "../../session" }

[features]
try-runtime = [
"frame-election-provider-support/try-runtime",
"frame-support/try-runtime",
"frame-system/try-runtime",
"pallet-bags-list/try-runtime",
"pallet-balances/try-runtime",
"pallet-election-provider-multi-phase/try-runtime",
"pallet-nomination-pools/try-runtime",
"pallet-session/try-runtime",
"pallet-staking/try-runtime",
"pallet-timestamp/try-runtime",
"sp-runtime/try-runtime",
]
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ fn log_current_time() {

#[test]
fn block_progression_works() {
let (mut ext, pool_state, _) = ExtBuilder::default().build_offchainify();
let (ext, pool_state, _) = ExtBuilder::default().build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
assert_eq!(active_era(), 0);
assert_eq!(Session::current_index(), 0);
assert!(ElectionProviderMultiPhase::current_phase().is_off());
Expand All @@ -70,9 +70,9 @@ fn block_progression_works() {
assert!(ElectionProviderMultiPhase::current_phase().is_signed());
});

let (mut ext, pool_state, _) = ExtBuilder::default().build_offchainify();
let (ext, pool_state, _) = ExtBuilder::default().build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
assert_eq!(active_era(), 0);
assert_eq!(Session::current_index(), 0);
assert!(ElectionProviderMultiPhase::current_phase().is_off());
Expand All @@ -93,12 +93,12 @@ fn offchainify_works() {

let staking_builder = StakingExtBuilder::default();
let epm_builder = EpmExtBuilder::default();
let (mut ext, pool_state, _) = ExtBuilder::default()
let (ext, pool_state, _) = ExtBuilder::default()
.epm(epm_builder)
.staking(staking_builder)
.build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
// test ocw progression and solution queue if submission when unsigned phase submission is
// not delayed.
for _ in 0..100 {
Expand Down Expand Up @@ -142,9 +142,9 @@ fn offchainify_works() {
/// restarts. Note that in this test case, the emergency throttling is disabled.
fn enters_emergency_phase_after_forcing_before_elect() {
let epm_builder = EpmExtBuilder::default().disable_emergency_throttling();
let (mut ext, pool_state, _) = ExtBuilder::default().epm(epm_builder).build_offchainify();
let (ext, pool_state, _) = ExtBuilder::default().epm(epm_builder).build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
log!(
trace,
"current validators (staking): {:?}",
Expand Down Expand Up @@ -213,12 +213,12 @@ fn continous_slashes_below_offending_threshold() {
let staking_builder = StakingExtBuilder::default().validator_count(10);
let epm_builder = EpmExtBuilder::default().disable_emergency_throttling();

let (mut ext, pool_state, _) = ExtBuilder::default()
let (ext, pool_state, _) = ExtBuilder::default()
.epm(epm_builder)
.staking(staking_builder)
.build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
assert_eq!(Session::validators().len(), 10);
let mut active_validator_set = Session::validators();

Expand Down Expand Up @@ -271,12 +271,12 @@ fn set_validation_intention_after_chilled() {
use frame_election_provider_support::SortedListProvider;
use pallet_staking::{Event, Forcing, Nominators};

let (mut ext, pool_state, _) = ExtBuilder::default()
let (ext, pool_state, _) = ExtBuilder::default()
.epm(EpmExtBuilder::default())
.staking(StakingExtBuilder::default())
.build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
assert_eq!(active_era(), 0);
// validator is part of the validator set.
assert!(Session::validators().contains(&41));
Expand Down Expand Up @@ -334,10 +334,10 @@ fn set_validation_intention_after_chilled() {
fn ledger_consistency_active_balance_below_ed() {
use pallet_staking::{Error, Event};

let (mut ext, pool_state, _) =
let (ext, pool_state, _) =
ExtBuilder::default().staking(StakingExtBuilder::default()).build_offchainify();

ext.execute_with(|| {
execute_with(ext, || {
assert_eq!(Staking::ledger(11.into()).unwrap().active, 1000);

// unbonding total of active stake fails because the active ledger balance would fall
Expand Down Expand Up @@ -387,3 +387,141 @@ fn ledger_consistency_active_balance_below_ed() {
assert!(Staking::ledger(11.into()).is_err());
});
}

#[test]
/// Automatic withdraw of unlocking funds in staking propagates to the nomination pools and its
gpestana marked this conversation as resolved.
Show resolved Hide resolved
/// state correctly.
///
/// The staking pallet may withdraw unlocking funds from a pool's bonded account without a pool
/// member or operator calling explicitly `Call::withdraw*`. This test verifies that the member's
/// are eventually paid and the `TotalValueLocked` is kept in sync in those cases.
fn automatic_unbonding_pools() {
use pallet_nomination_pools::TotalValueLocked;

// closure to fetch the staking unlocking chunks of an account.
let unlocking_chunks_of = |account: AccountId| -> usize {
Staking::ledger(sp_staking::StakingAccount::Controller(account))
.unwrap()
.unlocking
.len()
};

let (ext, pool_state, _) = ExtBuilder::default()
.pools(PoolsExtBuilder::default().max_unbonding(1))
.staking(StakingExtBuilder::default().max_unlocking(1).bonding_duration(2))
.build_offchainify();

execute_with(ext, || {
assert_eq!(<Runtime as pallet_staking::Config>::MaxUnlockingChunks::get(), 1);
assert_eq!(<Runtime as pallet_staking::Config>::BondingDuration::get(), 2);
assert_eq!(<Runtime as pallet_nomination_pools::Config>::MaxUnbonding::get(), 1);

// init state of pool members.
let init_free_balance_2 = Balances::free_balance(2);
let init_free_balance_3 = Balances::free_balance(3);

let pool_bonded_account = Pools::create_bonded_account(1);

// creates a pool with 5 bonded, owned by 1.
assert_ok!(Pools::create(RuntimeOrigin::signed(1), 5, 1, 1, 1));
assert_eq!(locked_amount_for(pool_bonded_account), 5);

let init_tvl = TotalValueLocked::<Runtime>::get();

// 2 joins the pool.
assert_ok!(Pools::join(RuntimeOrigin::signed(2), 10, 1));
assert_eq!(locked_amount_for(pool_bonded_account), 15);

// 3 joins the pool.
assert_ok!(Pools::join(RuntimeOrigin::signed(3), 10, 1));
assert_eq!(locked_amount_for(pool_bonded_account), 25);

assert_eq!(TotalValueLocked::<Runtime>::get(), 25);

// currently unlocking 0 chunks in the bonded pools ledger.
assert_eq!(unlocking_chunks_of(pool_bonded_account), 0);

// unbond 2 from pool.
assert_ok!(Pools::unbond(RuntimeOrigin::signed(2), 2, 10));

// amount is still locked in the pool, needs to wait for unbonding period.
assert_eq!(locked_amount_for(pool_bonded_account), 25);

// max chunks in the ledger are now filled up (`MaxUnlockingChunks == 1`).
assert_eq!(unlocking_chunks_of(pool_bonded_account), 1);

// tries to unbond 3 from pool. it will fail since there are no unlocking chunks left
// available and the current in the queue haven't been there for more than bonding
// duration.
assert_err!(
Pools::unbond(RuntimeOrigin::signed(3), 3, 10),
pallet_staking::Error::<Runtime>::NoMoreChunks
);

assert_eq!(current_era(), 0);

// progress over bonding duration.
for _ in 0..=<Runtime as pallet_staking::Config>::BondingDuration::get() {
start_next_active_era(pool_state.clone()).unwrap();
}
assert_eq!(current_era(), 3);
System::reset_events();

let locked_before_withdraw_pool = locked_amount_for(pool_bonded_account);
assert_eq!(Balances::free_balance(pool_bonded_account), 26);

// now unbonding 3 will work, although the pool's ledger still has the unlocking chunks
// filled up.
assert_ok!(Pools::unbond(RuntimeOrigin::signed(3), 3, 10));
assert_eq!(unlocking_chunks_of(pool_bonded_account), 1);

assert_eq!(
staking_events(),
[
// auto-withdraw happened as expected to release 2's unbonding funds, but the funds
// were not transfered to 2 and stay in the pool's tranferrable balance instead.
pallet_staking::Event::Withdrawn { stash: 7939698191839293293, amount: 10 },
pallet_staking::Event::Unbonded { stash: 7939698191839293293, amount: 10 }
]
);

// balance of the pool remains the same, it hasn't withdraw explicitly from the pool yet.
assert_eq!(Balances::free_balance(pool_bonded_account), 26);
// but the locked amount in the pool's account decreases due to the auto-withdraw:
assert_eq!(locked_before_withdraw_pool - 10, locked_amount_for(pool_bonded_account));

// TVL correctly updated.
assert_eq!(TotalValueLocked::<Runtime>::get(), 25 - 10);

// however, note that the withdrawing from the pool still works for 2, the funds are taken
// from the pool's free balance.
assert_eq!(Balances::free_balance(pool_bonded_account), 26);
assert_ok!(Pools::withdraw_unbonded(RuntimeOrigin::signed(2), 2, 10));
assert_eq!(Balances::free_balance(pool_bonded_account), 16);

assert_eq!(Balances::free_balance(2), 20);
assert_eq!(TotalValueLocked::<Runtime>::get(), 15);

// 3 cannot withdraw yet.
assert_err!(
Pools::withdraw_unbonded(RuntimeOrigin::signed(3), 3, 10),
pallet_nomination_pools::Error::<Runtime>::CannotWithdrawAny
);

// progress over bonding duration.
for _ in 0..=<Runtime as pallet_staking::Config>::BondingDuration::get() {
start_next_active_era(pool_state.clone()).unwrap();
}
assert_eq!(current_era(), 6);
System::reset_events();

assert_ok!(Pools::withdraw_unbonded(RuntimeOrigin::signed(3), 3, 10));

// final conditions are the expected.
assert_eq!(Balances::free_balance(pool_bonded_account), 6); // 5 init bonded + ED
assert_eq!(Balances::free_balance(2), init_free_balance_2);
assert_eq!(Balances::free_balance(3), init_free_balance_3);

assert_eq!(TotalValueLocked::<Runtime>::get(), init_tvl);
});
}
Loading
Loading