From b0681c3cae8e4aa1667eaceaf90c53d4940a3407 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Rios <54085674+JuaniRios@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:55:31 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Slashing=20behavior=20for=20pallet?= =?UTF-8?q?=20vesting=20(#396)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? - Reduce vesting schedules of pallet-vesting after a slash is made when an evaluation is settled ## Why? - A user could have negative transferable balance if they had some tokens locked for vesting and then got slashed. ## How? - Semi-generic solution which should be easily adapted to the Polkadot SDK. PR is [here](https://github.com/paritytech/polkadot-sdk/pull/5623). - pallet-funding (in the future pallet-balances) accepts a tuple of items that implement a trait called on_slash. - pallet funding calls this after slashing the evaluator (we don't use the slash interface so we call the trait directly. In the future this trait should also be called when using the slash function) - We implement on pallet vesting the trait where we see how many tokens should be released at the moment of slashing, and then apply the slash on the remaining frozen amount. We recalculate the per_block amount to keep the same end block ## Testing? - 2 tests in the new crate on-slash-vesting - 1 integration test ## Anything Else? - For now the trait for slashing needs to be in the same crate we implement it since we can't impl a foreign trait on a foreign crate. - Soon we should submit a PR to polkadot-sdk where we submit this new slash interface on the tokens::fungible trait, and also add our vesting impl directly inside pallet-vesting. --- Cargo.lock | 20 ++ Cargo.toml | 2 + integration-tests/Cargo.toml | 39 +++- integration-tests/src/constants.rs | 74 +++++- integration-tests/src/tests/ct_migration.rs | 6 +- integration-tests/src/tests/e2e.rs | 16 +- .../src/tests/evaluator_slash_sideffects.rs | 220 ++++++++++++++++++ integration-tests/src/tests/mod.rs | 1 + pallets/funding/Cargo.toml | 3 + pallets/funding/src/functions/6_settlement.rs | 3 + pallets/funding/src/lib.rs | 4 + pallets/funding/src/mock.rs | 1 + pallets/on-slash-vesting/Cargo.toml | 50 ++++ pallets/on-slash-vesting/src/lib.rs | 55 +++++ pallets/on-slash-vesting/src/mock.rs | 108 +++++++++ pallets/on-slash-vesting/src/test.rs | 86 +++++++ runtimes/polimec/Cargo.toml | 8 +- runtimes/polimec/src/lib.rs | 1 + 18 files changed, 678 insertions(+), 19 deletions(-) create mode 100644 integration-tests/src/tests/evaluator_slash_sideffects.rs create mode 100644 pallets/on-slash-vesting/Cargo.toml create mode 100644 pallets/on-slash-vesting/src/lib.rs create mode 100644 pallets/on-slash-vesting/src/mock.rs create mode 100644 pallets/on-slash-vesting/src/test.rs diff --git a/Cargo.lock b/Cargo.lock index d4d553822..48f0a31f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,7 @@ dependencies = [ "frame-system-benchmarking", "frame-system-rpc-runtime-api", "frame-try-runtime", + "hex-literal", "kusama-runtime-constants", "log", "pallet-asset-conversion", @@ -6528,6 +6529,23 @@ dependencies = [ "asn1-rs", ] +[[package]] +name = "on-slash-vesting" +version = "0.8.0" +dependencies = [ + "frame-support", + "frame-system", + "impl-trait-for-tuples", + "log", + "pallet-balances", + "pallet-vesting", + "parity-scale-codec", + "scale-info", + "serde", + "sp-io", + "sp-runtime", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -7258,6 +7276,7 @@ dependencies = [ "itertools 0.11.0", "log", "macros", + "on-slash-vesting", "pallet-assets", "pallet-balances", "pallet-insecure-randomness-collective-flip", @@ -8772,6 +8791,7 @@ dependencies = [ "frame-try-runtime", "hex-literal", "log", + "on-slash-vesting", "orml-oracle", "pallet-assets", "pallet-aura", diff --git a/Cargo.toml b/Cargo.toml index 69a4d5a8f..8e93ee393 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ pallet-sandbox = { path = "pallets/sandbox", default-features = false } pallet-parachain-staking = { path = "pallets/parachain-staking", default-features = false } pallet-linear-release = { path = "pallets/linear-release", default-features = false } polimec-receiver = { path = "pallets/polimec-receiver", default-features = false } +on-slash-vesting = { path = "pallets/on-slash-vesting", default-features = false } # Internal macros macros = { path = "macros" } @@ -109,6 +110,7 @@ color-print = "0.3.5" xcm-emulator = { version = "0.12.0", default-features = false } # Substrate (with default disabled) +impl-trait-for-tuples = { version = "0.2.2", default-features = false } frame-benchmarking = { version = "35.0.0", default-features = false } frame-benchmarking-cli = { version = "39.0.0" } frame-executive = { version = "35.0.0", default-features = false } diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 69a8d18b7..91a3f5f38 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -140,5 +140,42 @@ std = [ "xcm/std", ] development-settings = [ "polimec-runtime/development-settings" ] -runtime-benchmarks = [] +runtime-benchmarks = [ + "asset-hub-polkadot-runtime/runtime-benchmarks", + "polkadot-runtime/runtime-benchmarks", + "penpal-runtime/runtime-benchmarks", + "pallet-democracy/runtime-benchmarks", + "pallet-dispenser/runtime-benchmarks", + "pallet-elections-phragmen/runtime-benchmarks", + "pallet-funding/runtime-benchmarks", + "pallet-linear-release/runtime-benchmarks", + "pallet-parachain-staking/runtime-benchmarks", + "polimec-receiver/runtime-benchmarks", + "polimec-common/runtime-benchmarks", + "polimec-common-test-utils/runtime-benchmarks", + "polimec-runtime/runtime-benchmarks", + "cumulus-primitives-core/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "orml-oracle/runtime-benchmarks", + "pallet-assets/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "pallet-collective/runtime-benchmarks", + "pallet-im-online/runtime-benchmarks", + "pallet-membership/runtime-benchmarks", + "pallet-message-queue/runtime-benchmarks", + "pallet-scheduler/runtime-benchmarks", + "pallet-staking/runtime-benchmarks", + "pallet-treasury/runtime-benchmarks", + "pallet-vesting/runtime-benchmarks", + "pallet-xcm/runtime-benchmarks", + "parachains-common/runtime-benchmarks", + "polkadot-parachain-primitives/runtime-benchmarks", + "polkadot-primitives/runtime-benchmarks", + "polkadot-runtime-parachains/runtime-benchmarks", + "polkadot-service/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", + "xcm-builder/runtime-benchmarks", + "xcm-executor/runtime-benchmarks" +] diff --git a/integration-tests/src/constants.rs b/integration-tests/src/constants.rs index 47b16ca81..f57e21e64 100644 --- a/integration-tests/src/constants.rs +++ b/integration-tests/src/constants.rs @@ -64,6 +64,69 @@ fn get_from_seed(seed: &str) -> ::Public TPublic::Pair::from_string(&format!("//{}", seed), None).expect("static values are valid; qed").public() } +pub struct Prices { + pub dot: FixedU128, + pub usdc: FixedU128, + pub usdt: FixedU128, + pub plmc: FixedU128, +} + +// PricesBuilder for optional fields before building Prices +#[derive(Clone, Copy)] +pub struct PricesBuilder { + dot: Option, + usdc: Option, + usdt: Option, + plmc: Option, +} + +impl PricesBuilder { + // Initialize a new builder with None for each field + pub fn new() -> Self { + Self { dot: None, usdc: None, usdt: None, plmc: None } + } + + pub fn default() -> Prices { + Prices { + dot: FixedU128::from_rational(69, 1), + usdc: FixedU128::from_rational(1, 1), + usdt: FixedU128::from_rational(1, 1), + plmc: FixedU128::from_rational(840, 100), + } + } + + // Setters that take FixedU128 and return &mut self for chaining + pub fn dot(&mut self, price: FixedU128) -> &mut Self { + self.dot = Some(price); + self + } + + pub fn usdc(&mut self, price: FixedU128) -> &mut Self { + self.usdc = Some(price); + self + } + + pub fn usdt(&mut self, price: FixedU128) -> &mut Self { + self.usdt = Some(price); + self + } + + pub fn plmc(&mut self, price: FixedU128) -> &mut Self { + self.plmc = Some(price); + self + } + + // Build Prices using provided values or default values + pub fn build(self) -> Prices { + Prices { + dot: self.dot.unwrap_or(FixedU128::from_rational(69, 1)), // Default DOT price + usdc: self.usdc.unwrap_or(FixedU128::from_rational(1, 1)), // Default USDC price + usdt: self.usdt.unwrap_or(FixedU128::from_rational(1, 1)), // Default USDT price + plmc: self.plmc.unwrap_or(FixedU128::from_rational(840, 100)), // Default PLMC price + } + } +} + pub mod accounts { use super::*; pub const ALICE: &str = "Alice"; @@ -377,13 +440,12 @@ pub mod polimec { const GENESIS_PARACHAIN_BOND_RESERVE_PERCENT: Percent = Percent::from_percent(0); const GENESIS_NUM_SELECTED_CANDIDATES: u32 = 5; - #[allow(unused)] - pub fn set_prices() { + pub fn set_prices(prices: Prices) { PolimecNet::execute_with(|| { - let dot = (AcceptedFundingAsset::DOT.id(), FixedU128::from_rational(69, 1)); - let usdc = (AcceptedFundingAsset::USDC.id(), FixedU128::from_rational(1, 1)); - let usdt = (AcceptedFundingAsset::USDT.id(), FixedU128::from_rational(1, 1)); - let plmc = (pallet_funding::PLMC_FOREIGN_ID, FixedU128::from_rational(840, 100)); + let dot = (AcceptedFundingAsset::DOT.id(), prices.dot); + let usdc = (AcceptedFundingAsset::USDC.id(), prices.usdc); + let usdt = (AcceptedFundingAsset::USDT.id(), prices.usdt); + let plmc = (pallet_funding::PLMC_FOREIGN_ID, prices.plmc); let values: BoundedVec<(u32, FixedU128), ::MaxFeedValues> = vec![dot, usdc, usdt, plmc].try_into().expect("benchmarks can panic"); diff --git a/integration-tests/src/tests/ct_migration.rs b/integration-tests/src/tests/ct_migration.rs index 25ee151d2..95d1e66c4 100644 --- a/integration-tests/src/tests/ct_migration.rs +++ b/integration-tests/src/tests/ct_migration.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use crate::*; +use crate::{constants::PricesBuilder, *}; use frame_support::traits::{fungible::Mutate, fungibles::Inspect}; use itertools::Itertools; use pallet_funding::{assert_close_enough, types::*, ProjectId, WeightInfo}; @@ -190,7 +190,7 @@ fn create_settled_project() -> (ProjectId, Vec) { #[test] fn full_pallet_migration_test() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let (project_id, participants) = create_settled_project(); let _project_status = PolimecNet::execute_with(|| pallet_funding::ProjectsDetails::::get(project_id).unwrap().status); @@ -296,7 +296,7 @@ fn create_project_with_unsettled_participation(participation_type: Participation #[test] fn cannot_start_pallet_migration_with_unsettled_participations() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let tup_1 = create_project_with_unsettled_participation(ParticipationType::Evaluation); let tup_2 = create_project_with_unsettled_participation(ParticipationType::Bid); diff --git a/integration-tests/src/tests/e2e.rs b/integration-tests/src/tests/e2e.rs index dca184454..d77c229c6 100644 --- a/integration-tests/src/tests/e2e.rs +++ b/integration-tests/src/tests/e2e.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use crate::{tests::defaults::*, *}; +use crate::{constants::PricesBuilder, tests::defaults::*, *}; use frame_support::{ traits::{ fungible::Mutate, @@ -271,7 +271,7 @@ fn excel_ct_amounts() -> UserToCTBalance { #[test] fn evaluation_round_completed() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -286,7 +286,7 @@ fn evaluation_round_completed() { #[test] fn auction_round_completed() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -326,7 +326,7 @@ fn auction_round_completed() { #[test] fn community_round_completed() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -353,7 +353,7 @@ fn community_round_completed() { #[test] fn remainder_round_completed() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -386,7 +386,7 @@ fn remainder_round_completed() { #[test] fn funds_raised() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -418,7 +418,7 @@ fn funds_raised() { #[test] fn ct_minted() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); @@ -445,7 +445,7 @@ fn ct_minted() { #[test] fn ct_migrated() { - polimec::set_prices(); + polimec::set_prices(PricesBuilder::default()); let mut inst = IntegrationInstantiator::new(None); diff --git a/integration-tests/src/tests/evaluator_slash_sideffects.rs b/integration-tests/src/tests/evaluator_slash_sideffects.rs new file mode 100644 index 000000000..bf9633842 --- /dev/null +++ b/integration-tests/src/tests/evaluator_slash_sideffects.rs @@ -0,0 +1,220 @@ +use crate::{constants::PricesBuilder, tests::defaults::*, *}; +use frame_support::traits::fungible::Mutate; +use frame_system::{pallet_prelude::BlockNumberFor, Account}; +use macros::generate_accounts; +use pallet_balances::AccountData; +use pallet_funding::*; +use pallet_vesting::VestingInfo; +use polimec_common::USD_UNIT; +use polimec_runtime::PLMC; +use sp_arithmetic::Perquintill; +use sp_runtime::{FixedU128, MultiAddress::Id}; + +generate_accounts!(STASH, ALICE, BOB, CHARLIE, DAVE, ISSUER); + +#[test] +fn evaluator_slash_reduces_vesting_schedules() { + // Set PLMC price to 1 USD + let prices = PricesBuilder::new().plmc(FixedU128::from_float(1.0)).build(); + polimec::set_prices(prices); + + let mut inst = IntegrationInstantiator::new(None); + let alice: PolimecAccountId = ALICE.into(); + let bob: PolimecAccountId = BOB.into(); + + PolimecNet::execute_with(|| { + // Account that does the vested transfers (i.e. treasury) + PolimecBalances::set_balance(&STASH.into(), 1_000_000 * PLMC); + // For alice, we try to slash 4 schedules, each with a different duration and amount. + // One schedule should dissapear to due being fully vested before the slash, the other ones should be modified + PolimecBalances::set_balance(&alice.clone(), 0); + // For bob, we try to slash 1 schedule, which makes it dissapear since the slash amount is higher than the amount remaining for vesting + PolimecBalances::set_balance(&bob.clone(), BOB_STARTING_BALANCE); + + let slash_percent = ::EvaluatorSlash::get(); + + const BOB_STARTING_BALANCE: u128 = 100_000 * PLMC; + const LOCK_1: u128 = 10_000 * PLMC; + const LOCK_2: u128 = 13_000 * PLMC; + const LOCK_3: u128 = 7_500 * PLMC; + const LOCK_4: u128 = 20_000 * PLMC; + const PER_BLOCK_1: u128 = 10 * PLMC; + const PER_BLOCK_2: u128 = 650 * PLMC; + const PER_BLOCK_3: u128 = 5 * PLMC; + const PER_BLOCK_4: u128 = 200 * PLMC; + const DURATION_1: u128 = LOCK_1 / PER_BLOCK_1; + const DURATION_3: u128 = LOCK_3 / PER_BLOCK_3; + const DURATION_4: u128 = LOCK_4 / PER_BLOCK_4; + + // Duration 1000 blocks + let vesting_info_1 = VestingInfo::new(LOCK_1, PER_BLOCK_1, 5); + // Duration 20 blocks + let vesting_info_2 = VestingInfo::new(LOCK_2, PER_BLOCK_2, 5); + // Duration 1500 blocks + let vesting_info_3 = VestingInfo::new(LOCK_3, PER_BLOCK_3, 5); + // Duration 100 blocks + let vesting_info_4 = VestingInfo::new(LOCK_4, PER_BLOCK_4, 5); + + let total_alice_transferred = LOCK_1 + LOCK_2 + LOCK_3 + LOCK_4; + assert_ok!(PolimecVesting::vested_transfer( + PolimecOrigin::signed(STASH.into()), + Id(alice.clone()), + vesting_info_1 + )); + assert_ok!(PolimecVesting::vested_transfer( + PolimecOrigin::signed(STASH.into()), + Id(alice.clone()), + vesting_info_2 + )); + assert_ok!(PolimecVesting::vested_transfer( + PolimecOrigin::signed(STASH.into()), + Id(alice.clone()), + vesting_info_3 + )); + assert_ok!(PolimecVesting::vested_transfer( + PolimecOrigin::signed(STASH.into()), + Id(alice.clone()), + vesting_info_4 + )); + + let alice_evaluation = UserToUSDBalance::::new(alice.clone(), 35_000 * USD_UNIT); + let alice_plmc_evaluated = + inst.calculate_evaluation_plmc_spent(vec![alice_evaluation.clone()], false)[0].plmc_amount; + let alice_slashed = slash_percent * alice_plmc_evaluated; + + const BOB_EVALUATION: u128 = 60_000; + // We want the amount to be slashed to be higher than the amount remaining for vesting, after unlocking some tokens + let lock_5: u128 = ((slash_percent * BOB_EVALUATION) * PLMC) + PER_BLOCK_5 * 10; + const PER_BLOCK_5: u128 = 100 * PLMC; + let vesting_info_5 = VestingInfo::new(lock_5, PER_BLOCK_5, 5); + + assert_ok!(PolimecVesting::vested_transfer( + PolimecOrigin::signed(STASH.into()), + Id(bob.clone()), + vesting_info_5 + )); + let bob_evaluation = UserToUSDBalance::::new(bob.clone(), BOB_EVALUATION * USD_UNIT); + let bob_plmc_evaluated = + inst.calculate_evaluation_plmc_spent(vec![bob_evaluation.clone()], false)[0].plmc_amount; + let bob_slashed = slash_percent * bob_plmc_evaluated; + + // Set metadata so 50k USD succeeds the evaluation round + let mut project_metadata = default_project_metadata(ISSUER.into()); + project_metadata.total_allocation_size = 5_000 * CT_UNIT; + + // Create a project where alice and bob evaluated making the round successful, but then the project fails funding at block 25. + let project_id = inst.create_evaluating_project(project_metadata, ISSUER.into(), None); + assert_ok!(inst.evaluate_for_users(project_id, vec![alice_evaluation.clone(), bob_evaluation.clone()])); + assert_eq!(ProjectStatus::AuctionRound, inst.go_to_next_state(project_id)); + assert!(matches!(inst.go_to_next_state(project_id), ProjectStatus::CommunityRound(_))); + assert_eq!(ProjectStatus::FundingFailed, inst.go_to_next_state(project_id)); + assert_eq!(ProjectStatus::SettlementStarted(FundingOutcome::Failure), inst.go_to_next_state(project_id)); + assert_eq!(inst.current_block(), BlockNumberFor::::from(25u32)); + + // All schedules start at block 5, and funding ended at block 25 + const TIME_PASSED: u128 = 20u128; + + let alice_account_data = Account::::get(&alice.clone()).data; + assert_eq!( + alice_account_data, + AccountData { + free: total_alice_transferred - alice_plmc_evaluated, + reserved: alice_plmc_evaluated, + frozen: total_alice_transferred, + flags: Default::default(), + } + ); + assert_eq!(PolimecBalances::usable_balance(alice.clone()), 0); + + // vest schedule 2 was fully vested + assert_ok!(PolimecVesting::vest(PolimecOrigin::signed(alice.clone()))); + let alice_account_data = Account::::get(&alice.clone()).data; + let vested = (PER_BLOCK_1 + PER_BLOCK_2 + PER_BLOCK_3 + PER_BLOCK_4) * TIME_PASSED; + + let free = total_alice_transferred - alice_plmc_evaluated; + let reserved = alice_plmc_evaluated; + let frozen = total_alice_transferred - vested; + + // `untouchable` is the amount we need to substract from the free balance to get the usable balance. + // When the reserved amount is higher than the frozen amount, it means that the frozen balance restriction is fully covered by the already reserved tokens + // When the frozen amount is higher, it means we need to use some free tokens to cover this restriction. + // This amount can never be below the existential deposit. + let untouchable = frozen.saturating_sub(reserved).max(inst.get_ed()); + + assert_eq!(alice_account_data, AccountData { free, reserved, frozen, flags: Default::default() }); + assert_eq!(PolimecBalances::usable_balance(alice.clone()), free - untouchable); + + assert_ok!(PolimecFunding::settle_evaluation( + PolimecOrigin::signed(alice.clone()), + project_id, + alice.clone(), + 0 + )); + + let alice_schedules = >::get(alice.clone()).unwrap().to_vec(); + let new_lock_1 = LOCK_1 - (PER_BLOCK_1 * TIME_PASSED) - alice_slashed; + let new_lock_3 = LOCK_3 - (PER_BLOCK_3 * TIME_PASSED) - alice_slashed; + let new_lock_4 = LOCK_4 - (PER_BLOCK_4 * TIME_PASSED) - alice_slashed; + const TIME_REMAINING_1: u128 = DURATION_1 - TIME_PASSED; + const TIME_REMAINING_3: u128 = DURATION_3 - TIME_PASSED; + const TIME_REMAINING_4: u128 = DURATION_4 - TIME_PASSED; + let new_per_block_1 = new_lock_1 / TIME_REMAINING_1; + let new_per_block_3 = new_lock_3 / TIME_REMAINING_3; + let new_per_block_4 = new_lock_4 / TIME_REMAINING_4; + + let alice_account_data = Account::::get(&alice.clone()).data; + let free = free + alice_plmc_evaluated - alice_slashed; + let frozen = new_lock_1 + new_lock_3 + new_lock_4; + let reserved = 0u128; + let untouchable = frozen.saturating_sub(reserved).max(inst.get_ed()); + + assert_eq!( + alice_schedules, + vec![ + VestingInfo::new(new_lock_1, new_per_block_1, 25), + VestingInfo::new(new_lock_3, new_per_block_3, 25), + VestingInfo::new(new_lock_4, new_per_block_4, 25), + ] + ); + assert_eq!(alice_account_data, AccountData { free, reserved, frozen, flags: Default::default() }); + assert_eq!(PolimecBalances::usable_balance(alice.clone()), free - untouchable); + + let bob_account_data = Account::::get(&bob.clone()).data; + let free = BOB_STARTING_BALANCE + lock_5 - bob_plmc_evaluated; + let reserved = bob_plmc_evaluated; + let frozen = lock_5; + assert_eq!(bob_account_data, AccountData { free, reserved, frozen, flags: Default::default() }); + let untouchable = frozen.saturating_sub(reserved).max(inst.get_ed()); + assert_eq!(PolimecBalances::usable_balance(bob.clone()), free - untouchable); + + // Schedule has some tokens vested, and the remaining locked amount is lower than the slash about to occur + assert_ok!(PolimecVesting::vest(PolimecOrigin::signed(bob.clone()))); + let bob_account_data = Account::::get(&bob.clone()).data; + let vested = PER_BLOCK_5 * TIME_PASSED; + + let free = BOB_STARTING_BALANCE + lock_5 - bob_plmc_evaluated; + let reserved = bob_plmc_evaluated; + let frozen = lock_5 - vested; + let untouchable = frozen.saturating_sub(reserved).max(inst.get_ed()); + assert_eq!(bob_account_data, AccountData { free, reserved, frozen, flags: Default::default() }); + assert_eq!(PolimecBalances::usable_balance(bob.clone()), free - untouchable); + + // Here the slash amount is higher than the amount remaining for vesting, so the schedule should dissapear + assert_ok!(PolimecFunding::settle_evaluation(PolimecOrigin::signed(bob.clone()), project_id, bob.clone(), 1)); + assert!(bob_slashed > lock_5 - vested && bob_slashed < lock_5); + + assert!(>::get(bob.clone()).is_none()); + let bob_account_data = Account::::get(&bob.clone()).data; + let free = free + bob_plmc_evaluated - bob_slashed; + let frozen = 0u128; + let reserved = 0u128; + let untouchable = frozen.saturating_sub(reserved).max(inst.get_ed()); + + assert_eq!(bob_account_data, AccountData { free, reserved, frozen, flags: Default::default() }); + assert_close_enough!( + PolimecBalances::usable_balance(bob.clone()), + free - untouchable, + Perquintill::from_float(0.999) + ); + }); +} diff --git a/integration-tests/src/tests/mod.rs b/integration-tests/src/tests/mod.rs index 6453b6c40..d45335565 100644 --- a/integration-tests/src/tests/mod.rs +++ b/integration-tests/src/tests/mod.rs @@ -18,6 +18,7 @@ mod credentials; mod ct_migration; mod defaults; mod e2e; +mod evaluator_slash_sideffects; mod governance; mod oracle; mod reserve_backed_transfers; diff --git a/pallets/funding/Cargo.toml b/pallets/funding/Cargo.toml index c21ef635e..b9bbf8ebf 100644 --- a/pallets/funding/Cargo.toml +++ b/pallets/funding/Cargo.toml @@ -28,6 +28,7 @@ log.workspace = true variant_count = "1.1.0" pallet-linear-release.workspace = true +on-slash-vesting.workspace = true # Substrate dependencies frame-support.workspace = true @@ -70,6 +71,7 @@ std = [ "frame-system/std", "itertools/use_std", "log/std", + "on-slash-vesting/std", "pallet-assets/std", "pallet-balances/std", "pallet-insecure-randomness-collective-flip/std", @@ -112,6 +114,7 @@ runtime-benchmarks = [ "sp-runtime/runtime-benchmarks", "xcm-builder/runtime-benchmarks", "xcm-executor/runtime-benchmarks", + "on-slash-vesting/runtime-benchmarks" ] try-runtime = [ "frame-support/try-runtime", diff --git a/pallets/funding/src/functions/6_settlement.rs b/pallets/funding/src/functions/6_settlement.rs index 69635a103..f558ffd8d 100644 --- a/pallets/funding/src/functions/6_settlement.rs +++ b/pallets/funding/src/functions/6_settlement.rs @@ -11,6 +11,7 @@ use frame_support::{ Get, }, }; +use on_slash_vesting::OnSlash; use polimec_common::{ migration_types::{MigrationInfo, MigrationOrigin, MigrationStatus, ParticipationType}, ReleaseSchedule, @@ -393,6 +394,8 @@ impl Pallet { Fortitude::Force, )?; + T::OnSlash::on_slash(&evaluation.evaluator, slashed_amount); + Ok(evaluation.current_plmc_bond.saturating_sub(slashed_amount)) } diff --git a/pallets/funding/src/lib.rs b/pallets/funding/src/lib.rs index 09e16d2e0..c5c0b530b 100644 --- a/pallets/funding/src/lib.rs +++ b/pallets/funding/src/lib.rs @@ -156,6 +156,7 @@ pub mod pallet { traits::{OnFinalize, OnIdle, OnInitialize}, }; use frame_system::pallet_prelude::*; + use on_slash_vesting::OnSlash; use sp_arithmetic::Percent; use sp_runtime::{ traits::{Convert, ConvertBack, Get}, @@ -361,6 +362,9 @@ pub mod pallet { /// Struct holding information about extrinsic weights type WeightInfo: weights::WeightInfo; + + /// Callbacks for dealing with an evaluator slash on other pallets + type OnSlash: OnSlash, Balance>; } #[pallet::storage] diff --git a/pallets/funding/src/mock.rs b/pallets/funding/src/mock.rs index 7afcbab3c..1aeea9c79 100644 --- a/pallets/funding/src/mock.rs +++ b/pallets/funding/src/mock.rs @@ -418,6 +418,7 @@ impl Config for TestRuntime { type MinUsdPerEvaluation = MinUsdPerEvaluation; type Multiplier = Multiplier; type NativeCurrency = Balances; + type OnSlash = (); type PalletId = FundingPalletId; type Price = FixedU128; type PriceProvider = ConstPriceProvider; diff --git a/pallets/on-slash-vesting/Cargo.toml b/pallets/on-slash-vesting/Cargo.toml new file mode 100644 index 000000000..fc668a065 --- /dev/null +++ b/pallets/on-slash-vesting/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "on-slash-vesting" +authors.workspace = true +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +readme.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +pallet-vesting.workspace = true +impl-trait-for-tuples.workspace = true +frame-support.workspace = true +frame-system.workspace = true +pallet-balances.workspace = true +log.workspace = true +parity-scale-codec.workspace = true +scale-info.workspace = true +sp-runtime.workspace = true +sp-io.workspace = true +serde.workspace = true +[lints] +workspace = true + + +[features] +default = [ "std" ] + +std = [ + "frame-support/std", + "frame-system/std", + "log/std", + "pallet-balances/std", + "pallet-vesting/std", + "parity-scale-codec/std", + "scale-info/std", + "serde/std", + "sp-io/std", + "sp-runtime/std", +] + +runtime-benchmarks = [ + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-balances/runtime-benchmarks", + "pallet-vesting/runtime-benchmarks", + "sp-runtime/runtime-benchmarks" +] diff --git a/pallets/on-slash-vesting/src/lib.rs b/pallets/on-slash-vesting/src/lib.rs new file mode 100644 index 000000000..571f986a2 --- /dev/null +++ b/pallets/on-slash-vesting/src/lib.rs @@ -0,0 +1,55 @@ +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod test; + +use frame_support::{ + sp_runtime::{traits::Convert, FixedPointNumber, FixedU128}, + traits::{Currency, OriginTrait}, +}; +use pallet_vesting::Vesting; +use sp_runtime::{traits::BlockNumberProvider, BoundedVec}; + +pub trait OnSlash { + fn on_slash(account: &AccountId, amount: Balance); +} + +#[impl_trait_for_tuples::impl_for_tuples(30)] +impl OnSlash for Tuple { + fn on_slash(account: &AccountId, amount: Balance) { + for_tuples!( #( Tuple::on_slash(account, amount.clone()); )* ); + } +} + +type AccountIdOf = ::AccountId; +impl OnSlash, u128> for pallet_vesting::Pallet +where + T: pallet_vesting::Config, + T::Currency: Currency, Balance = u128>, +{ + fn on_slash(account: &AccountIdOf, slashed_amount: u128) { + if let Some(vesting_schedules) = >::get(account) { + let mut new_vesting_schedules = BoundedVec::with_bounded_capacity(vesting_schedules.len()); + let now = T::BlockNumberProvider::current_block_number(); + for schedule in vesting_schedules { + let total_locked = schedule.locked_at::(now).saturating_sub(slashed_amount); + let start_block = T::BlockNumberToBalance::convert(now); + let end_block = schedule.ending_block_as_balance::(); + let duration = end_block.saturating_sub(start_block); + let per_block = FixedU128::from_rational(total_locked, duration).saturating_mul_int(1u128); + let new_schedule = pallet_vesting::VestingInfo::new(total_locked, per_block, now); + if new_schedule.is_valid() { + // The push should always succeed because we are iterating over a bounded vector. + let push_result = new_vesting_schedules.try_push(new_schedule); + debug_assert!(push_result.is_ok()); + } + } + >::set(account, Some(new_vesting_schedules)); + let vest_result = >::vest(T::RuntimeOrigin::signed(account.clone())); + debug_assert!(vest_result.is_ok()); + } + } +} diff --git a/pallets/on-slash-vesting/src/mock.rs b/pallets/on-slash-vesting/src/mock.rs new file mode 100644 index 000000000..0e7aebb80 --- /dev/null +++ b/pallets/on-slash-vesting/src/mock.rs @@ -0,0 +1,108 @@ +use frame_support::{ + __private::RuntimeDebug, + derive_impl, parameter_types, + sp_runtime::{traits::IdentityLookup, BuildStorage}, + traits::{VariantCount, WithdrawReasons}, +}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use serde::{Deserialize, Serialize}; +use sp_runtime::traits::{ConvertInto, Identity}; + +frame_support::construct_runtime!( + pub enum TestRuntime { + System: frame_system = 0, + Balances: pallet_balances = 1, + Vesting: pallet_vesting = 2, + } +); +type Block = frame_system::mocking::MockBlock; + +#[derive( + Encode, + Decode, + Copy, + Clone, + PartialEq, + Eq, + RuntimeDebug, + MaxEncodedLen, + TypeInfo, + Ord, + PartialOrd, + Serialize, + Deserialize, +)] +pub enum MockRuntimeHoldReason { + Reason, + Reason2, +} +impl VariantCount for MockRuntimeHoldReason { + const VARIANT_COUNT: u32 = 2; +} + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig as frame_system::DefaultConfig)] +impl frame_system::Config for TestRuntime { + type AccountData = pallet_balances::AccountData; + type AccountId = u64; + type Block = Block; + type Lookup = IdentityLookup; +} + +parameter_types! { + pub const MinVestedTransfer: u64 = 10; + pub UnvestedFundsAllowedWithdrawReasons: WithdrawReasons = + WithdrawReasons::except(WithdrawReasons::TRANSFER | WithdrawReasons::RESERVE); + pub static ExistentialDeposit: u128 = 10u128.pow(7); +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig as pallet_balances::DefaultConfig)] +impl pallet_balances::Config for TestRuntime { + type AccountStore = System; + type Balance = u128; + type ExistentialDeposit = ExistentialDeposit; + type RuntimeHoldReason = MockRuntimeHoldReason; +} + +impl pallet_vesting::Config for TestRuntime { + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkReason = BenchmarkReason; + type BlockNumberProvider = System; + type BlockNumberToBalance = ConvertInto; + type Currency = Balances; + type MinVestedTransfer = MinVestedTransfer; + type RuntimeEvent = RuntimeEvent; + type UnvestedFundsAllowedWithdrawReasons = UnvestedFundsAllowedWithdrawReasons; + type WeightInfo = (); + + const MAX_VESTING_SCHEDULES: u32 = 6; +} + +#[derive(Default)] +pub struct ExtBuilder { + pub existential_deposit: u128, +} + +impl ExtBuilder { + pub fn existential_deposit(mut self, existential_deposit: u128) -> Self { + self.existential_deposit = existential_deposit; + self + } + + pub fn build(self) -> sp_io::TestExternalities { + EXISTENTIAL_DEPOSIT.with(|v| *v.borrow_mut() = self.existential_deposit); + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + pallet_balances::GenesisConfig:: { + balances: vec![ + (1, self.existential_deposit), + (2, self.existential_deposit), + (3, self.existential_deposit), + (4, self.existential_deposit), + ], + } + .assimilate_storage(&mut t) + .unwrap(); + + sp_io::TestExternalities::new(t) + } +} diff --git a/pallets/on-slash-vesting/src/test.rs b/pallets/on-slash-vesting/src/test.rs new file mode 100644 index 000000000..25947965e --- /dev/null +++ b/pallets/on-slash-vesting/src/test.rs @@ -0,0 +1,86 @@ +extern crate alloc; +use super::{mock::*, *}; +use frame_support::{ + assert_ok, + traits::tokens::fungible::{BalancedHold, Inspect, Mutate, MutateHold}, +}; +use mock::{Balances as PalletBalances, System as PalletSystem, Vesting as PalletVesting}; +use pallet_balances::AccountData; +use pallet_vesting::VestingInfo; + +#[test] +fn one_schedule() { + ExtBuilder { existential_deposit: 1 }.build().execute_with(|| { + >::set_balance(&1, 0); + >::set_balance(&2, 100); + let vesting_info = VestingInfo::new(100, 10, 1); + assert_ok!(PalletVesting::vested_transfer(RuntimeOrigin::signed(2), 1, vesting_info)); + assert_ok!(>::hold(&MockRuntimeHoldReason::Reason, &1u64, 30u128)); + + assert_eq!(PalletBalances::usable_balance(1), 0); + + PalletSystem::set_block_number(3); + // Unlock 20 + assert_ok!(PalletVesting::vest(RuntimeOrigin::signed(1))); + assert_eq!(PalletBalances::usable_balance(1), 20); + + // Slash 30 + >::slash(&MockRuntimeHoldReason::Reason, &1u64, 30u128); + >::on_slash(&1, 30); + + // After calling on_slash, the previously unlocked 20 should be available again + assert_eq!(PalletBalances::usable_balance(1), 20); + }); +} + +#[test] +fn multiple_schedules() { + ExtBuilder { existential_deposit: 1 }.build().execute_with(|| { + >::set_balance(&1, 0); + >::set_balance(&2, 100); + >::mint_into(&2, 130).unwrap(); + >::mint_into(&2, 75).unwrap(); + >::mint_into(&2, 200).unwrap(); + + // Duration 10 blocks + let vesting_info_1 = VestingInfo::new(100, 10, 1); + // Duration 2 blocks + let vesting_info_2 = VestingInfo::new(130, 65, 1); + // Duration 15 blocks + let vesting_info_3 = VestingInfo::new(75, 5, 1); + // Duration 10 blocks + let vesting_info_4 = VestingInfo::new(200, 20, 1); + + assert_ok!(PalletVesting::vested_transfer(RuntimeOrigin::signed(2), 1, vesting_info_1)); + assert_ok!(PalletVesting::vested_transfer(RuntimeOrigin::signed(2), 1, vesting_info_2)); + assert_ok!(PalletVesting::vested_transfer(RuntimeOrigin::signed(2), 1, vesting_info_3)); + assert_ok!(PalletVesting::vested_transfer(RuntimeOrigin::signed(2), 1, vesting_info_4)); + + assert_ok!(>::hold(&MockRuntimeHoldReason::Reason, &1u64, 100u128)); + assert_eq!(PalletBalances::usable_balance(1), 0); + + + PalletSystem::set_block_number(3); + + // Unlock 10*2 + 65*2 + 5*2 + 20*2 = 200 + assert_ok!(PalletVesting::vest(RuntimeOrigin::signed(1))); + assert_eq!(PalletBalances::usable_balance(1), 200); + + >::slash(&MockRuntimeHoldReason::Reason, &1u64, 65u128); + >::on_slash(&1, 65); + + let schedules = >::get(1).unwrap().to_vec(); + + // One schedule was fully vested before the slash, the other got the full amount reduced after the slash + assert_eq!(schedules, vec![VestingInfo::new(15, 1, 3), VestingInfo::new(95, 11, 3),]); + + assert_eq!( + PalletSystem::account(1).data, + AccountData { free: 405, reserved: 35, frozen: 110, flags: Default::default() } + ); + + // What part of the frozen restriction applies to the free balance after applying it to the slash + let untouchable = 110 - 35; + assert_eq!(PalletBalances::usable_balance(1), 405 - untouchable); + }); +} diff --git a/runtimes/polimec/Cargo.toml b/runtimes/polimec/Cargo.toml index acdfc9a5a..63c0b724d 100644 --- a/runtimes/polimec/Cargo.toml +++ b/runtimes/polimec/Cargo.toml @@ -37,6 +37,7 @@ pallet-linear-release.workspace = true shared-configuration.workspace = true polimec-common.workspace = true pallet-parachain-staking.workspace = true +on-slash-vesting.workspace = true # Substrate frame-benchmarking = { workspace = true, optional = true } @@ -129,6 +130,7 @@ std = [ "frame-system/std", "frame-try-runtime?/std", "log/std", + "on-slash-vesting/std", "orml-oracle/std", "pallet-assets/std", "pallet-aura/std", @@ -226,6 +228,7 @@ runtime-benchmarks = [ "sp-runtime/runtime-benchmarks", "xcm-builder/runtime-benchmarks", "xcm-executor/runtime-benchmarks", + "on-slash-vesting/runtime-benchmarks" ] try-runtime = [ @@ -275,6 +278,9 @@ try-runtime = [ # A feature that should be enabled when the runtime should be built for on-chain # deployment. This will disable stuff that shouldn't be part of the on-chain wasm # to make it smaller, like logging for example. -on-chain-release-build = [ "sp-api/disable-logging", "pallet-funding/on-chain-release-build" ] +on-chain-release-build = [ + "pallet-funding/on-chain-release-build", + "sp-api/disable-logging", +] development-settings = [ "shared-configuration/development-settings" ] diff --git a/runtimes/polimec/src/lib.rs b/runtimes/polimec/src/lib.rs index 03a22703d..953c3ed85 100644 --- a/runtimes/polimec/src/lib.rs +++ b/runtimes/polimec/src/lib.rs @@ -1057,6 +1057,7 @@ impl pallet_funding::Config for Runtime { type MinUsdPerEvaluation = MinUsdPerEvaluation; type Multiplier = pallet_funding::types::Multiplier; type NativeCurrency = Balances; + type OnSlash = Vesting; type PalletId = FundingPalletId; type Price = Price; type PriceProvider = OraclePriceProvider;