From 0426f76df5c32e0c5dd3cd9f73de56644ea64baa Mon Sep 17 00:00:00 2001 From: William Freudenberger Date: Tue, 23 Jan 2024 15:32:50 +0100 Subject: [PATCH] feat: Add Pool Fees (#1633) * chore: init blank pool fees pallet * chore: apply workspace #1609 to pool-fees dummy * fix: docker-compose * feat: add changeguard pool fees support * wip: prepare payment logic * refactor: use reserve instead of nav * chore: add extrinsics * feat: prep + pay disbursements * refactor: Apply fees ChangeGuard to #1637 * feat: add pool fees to all runtimes * feat: add fees pool registration * fix: existing pool unit tests * fix: existing integration tests * docs: add pool fee types * wip: init fees unit tests * tests: extrinsics wip * chore: add events * tests: add pool fees unit tests * fix: support retroactive disbursements * refactor: add epoch transition hook * refactor: add pool fee prefix to types * refactor: remove redundand trait bounds * wip: pool system integration tests * refactor: move portfolio valuation from loans to cfg-types * chore: add pool fee account id * wip: pool fee nav * wip: fix uts * wip: fix apply review by @lemunozm * fix: issues after rebase * tests: add saturated_proration * refactor: simplify pool fee amounts * chore: aum + fix fees UTs * chore: apply AUM to pool-system * fix: remove AUM coupling in PoolFees * fix: transfer on close, unit tests * fix: use total nav * fix: taplo * fix: fee calc on nav closing * feat: impl TimeAsSecs for timestamp mock * fix: test on_closing instead of update_active_fees * fix: clippy * tests: fix + add missing pool fees * refactor: make update fees result instead of void * tests: add insufficient resource in p-system * bench: add pool fees, apply to system + registry * fix: tests * refactor: explicitly use Seconds in FeeAmountProration impl * docs: add PoolFeeAmount and NAV update * refactor: update NAV, total assets after review from @mustermeiszer * fix: clippy * refactor: Add PoolFeePayable * fix: clippy * fix: correct epoch execution with fees (#1695) * fix: correct epoch execution with fees * refactor: use new nav syntax * tests: fix auto epoch closing * feat: epoch execution migration * chore: add epoch migration to altair --------- Co-authored-by: William Freudenberger --------- Co-authored-by: Guillermo Perez Co-authored-by: Frederik Gartenmeister --- Cargo.lock | 38 +- Cargo.toml | 5 +- libs/mocks/src/time.rs | 9 + libs/primitives/src/lib.rs | 11 + libs/traits/Cargo.toml | 2 + libs/traits/src/benchmarking.rs | 25 + libs/traits/src/fee.rs | 73 + libs/traits/src/lib.rs | 22 + libs/types/Cargo.toml | 2 +- libs/types/src/ids.rs | 1 + libs/types/src/lib.rs | 1 + libs/types/src/pools.rs | 310 ++++ .../src/types => libs/types/src}/portfolio.rs | 39 +- pallets/block-rewards/src/weights.rs | 24 +- pallets/keystore/src/benchmarking.rs | 2 +- pallets/keystore/src/lib.rs | 4 +- pallets/loans/Cargo.toml | 2 +- pallets/loans/src/lib.rs | 7 +- pallets/loans/src/types/mod.rs | 1 - pallets/pool-fees/Cargo.toml | 84 ++ pallets/pool-fees/src/benchmarking.rs | 169 +++ pallets/pool-fees/src/lib.rs | 886 ++++++++++++ pallets/pool-fees/src/mock.rs | 456 ++++++ pallets/pool-fees/src/tests.rs | 1278 +++++++++++++++++ pallets/pool-fees/src/types.rs | 25 + pallets/pool-registry/Cargo.toml | 4 + pallets/pool-registry/src/benchmarking.rs | 34 +- pallets/pool-registry/src/lib.rs | 18 +- pallets/pool-registry/src/mock.rs | 95 +- pallets/pool-registry/src/tests.rs | 3 +- pallets/pool-system/Cargo.toml | 9 +- pallets/pool-system/src/benchmarking.rs | 87 +- pallets/pool-system/src/impls.rs | 19 +- pallets/pool-system/src/lib.rs | 110 +- pallets/pool-system/src/mock.rs | 134 +- pallets/pool-system/src/solution.rs | 24 +- pallets/pool-system/src/tests/mod.rs | 723 +++++++++- pallets/pool-system/src/weights.rs | 24 +- runtime/altair/Cargo.toml | 4 + runtime/altair/src/lib.rs | 32 +- runtime/altair/src/migrations.rs | 2 + .../altair/src/weights/pallet_pool_system.rs | 12 +- runtime/centrifuge/Cargo.toml | 4 + runtime/centrifuge/src/lib.rs | 32 +- runtime/centrifuge/src/migrations.rs | 5 +- .../src/weights/pallet_pool_system.rs | 12 +- runtime/common/Cargo.toml | 4 + runtime/common/src/changes.rs | 16 +- runtime/common/src/fees.rs | 3 +- .../common/src/migrations/epoch_execution.rs | 142 ++ runtime/common/src/migrations/mod.rs | 1 + runtime/development/Cargo.toml | 6 +- runtime/development/src/lib.rs | 38 +- runtime/development/src/migrations.rs | 3 +- .../src/weights/pallet_pool_system.rs | 12 +- .../src/generic/cases/liquidity_pools.rs | 2 + .../src/generic/utils/mod.rs | 1 + .../tests/liquidity_pools/setup.rs | 1 + 58 files changed, 4876 insertions(+), 216 deletions(-) create mode 100644 libs/traits/src/fee.rs rename {pallets/loans/src/types => libs/types/src}/portfolio.rs (76%) create mode 100644 pallets/pool-fees/Cargo.toml create mode 100644 pallets/pool-fees/src/benchmarking.rs create mode 100644 pallets/pool-fees/src/lib.rs create mode 100644 pallets/pool-fees/src/mock.rs create mode 100644 pallets/pool-fees/src/tests.rs create mode 100644 pallets/pool-fees/src/types.rs create mode 100644 runtime/common/src/migrations/epoch_execution.rs create mode 100644 runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 1b0291cb33..66667fb5ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -266,6 +266,7 @@ dependencies = [ "pallet-oracle-feed", "pallet-order-book", "pallet-permissions", + "pallet-pool-fees", "pallet-pool-registry", "pallet-pool-system", "pallet-preimage", @@ -1201,6 +1202,7 @@ dependencies = [ "pallet-oracle-feed", "pallet-order-book", "pallet-permissions", + "pallet-pool-fees", "pallet-pool-registry", "pallet-pool-system", "pallet-preimage", @@ -1345,6 +1347,7 @@ dependencies = [ "sp-arithmetic", "sp-runtime", "sp-std", + "strum", ] [[package]] @@ -2735,7 +2738,7 @@ dependencies = [ [[package]] name = "development-runtime" -version = "0.10.37" +version = "0.10.38" dependencies = [ "axelar-gateway-precompile", "cfg-primitives", @@ -2809,6 +2812,7 @@ dependencies = [ "pallet-oracle-feed", "pallet-order-book", "pallet-permissions", + "pallet-pool-fees", "pallet-pool-registry", "pallet-pool-system", "pallet-preimage", @@ -8276,6 +8280,34 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-pool-fees" +version = "0.0.1" +dependencies = [ + "cfg-mocks", + "cfg-primitives", + "cfg-test-utils", + "cfg-traits", + "cfg-types", + "frame-benchmarking", + "frame-support", + "frame-system", + "log", + "orml-tokens", + "orml-traits", + "pallet-balances", + "pallet-timestamp", + "parity-scale-codec 3.6.5", + "rand 0.8.5", + "scale-info", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", + "strum", +] + [[package]] name = "pallet-pool-registry" version = "1.0.0" @@ -8294,6 +8326,7 @@ dependencies = [ "pallet-balances", "pallet-investments", "pallet-permissions", + "pallet-pool-fees", "pallet-pool-system", "pallet-timestamp", "parachain-info", @@ -8311,6 +8344,7 @@ dependencies = [ name = "pallet-pool-system" version = "3.0.0" dependencies = [ + "cfg-mocks", "cfg-primitives", "cfg-test-utils", "cfg-traits", @@ -8326,6 +8360,7 @@ dependencies = [ "pallet-balances", "pallet-investments", "pallet-permissions", + "pallet-pool-fees", "pallet-restricted-tokens", "pallet-timestamp", "parachain-info", @@ -11447,6 +11482,7 @@ dependencies = [ "pallet-oracle-feed", "pallet-order-book", "pallet-permissions", + "pallet-pool-fees", "pallet-pool-registry", "pallet-pool-system", "pallet-preimage", diff --git a/Cargo.toml b/Cargo.toml index 9a1f31776c..cb71501695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "pallets/oracle-collection", "pallets/order-book", "pallets/permissions", + "pallets/pool-fees", "pallets/pool-system", "pallets/pool-registry", "pallets/restricted-tokens", @@ -63,7 +64,7 @@ smallvec = "1.6.1" serde = { version = "1.0.119", features = ["derive"] } parity-scale-codec = { version = "3.0", default-features = false, features = ["derive"] } scale-info = { version = "2.3.0", default-features = false, features = ["derive"] } -log = "0.4" +log = { version = "0.4", default-features = false } getrandom = { version = "0.2", features = ["js"] } static_assertions = "1.1.0" lazy_static = "1.4.0" @@ -78,6 +79,7 @@ futures = "0.3.25" jsonrpsee = { version = "0.16.2", features = ["server", "macros"] } url = "2.2.2" tempfile = "3.1.0" +strum = { version = "0.24", default-features = false, features = ["derive"] } # Cumulus cumulus-pallet-aura-ext = { git = "https://github.com/paritytech/cumulus", default-features = false, branch = "polkadot-v0.9.43" } @@ -272,6 +274,7 @@ pallet-oracle-feed = { path = "pallets/oracle-feed", default-features = false } pallet-oracle-collection = { path = "pallets/oracle-collection", default-features = false } pallet-order-book = { path = "pallets/order-book", default-features = false } pallet-permissions = { path = "pallets/permissions", default-features = false } +pallet-pool-fees = { path = "pallets/pool-fees", default-features = false } pallet-pool-registry = { path = "pallets/pool-registry", default-features = false } pallet-pool-system = { path = "pallets/pool-system", default-features = false } pallet-restricted-tokens = { path = "pallets/restricted-tokens", default-features = false } diff --git a/libs/mocks/src/time.rs b/libs/mocks/src/time.rs index 41e46835c9..ea20565125 100644 --- a/libs/mocks/src/time.rs +++ b/libs/mocks/src/time.rs @@ -33,4 +33,13 @@ pub mod pallet { execute_call!(()) } } + + impl frame_support::traits::UnixTime for Pallet + where + T::Moment: Into, + { + fn now() -> std::time::Duration { + core::time::Duration::from_millis( as Time>::now().into()) + } + } } diff --git a/libs/primitives/src/lib.rs b/libs/primitives/src/lib.rs index 7483f79c4a..62d57baae2 100644 --- a/libs/primitives/src/lib.rs +++ b/libs/primitives/src/lib.rs @@ -176,6 +176,9 @@ pub mod types { /// The type for indexing pallets on a Substrate runtime pub type PalletIndex = u8; + + /// The representation of a pool fee identifier + pub type PoolFeeId = u64; } /// Common constants for all runtimes @@ -272,6 +275,14 @@ pub mod constants { pub const MAX_POV_SIZE: u64 = cumulus_primitives_core::relay_chain::MAX_POV_SIZE as u64; /// Block storage limit in bytes. Set to 40 KB. pub const BLOCK_STORAGE_LIMIT: u64 = 40 * 1024; + + /// The maximum number of fees per pool. + /// + /// NOTE: Must be ge than [MAX_POOL_FEES_PER_BUCKET]. + pub const MAX_FEES_PER_POOL: u32 = 200; + + /// The maximum number of pool fees per pool fee bucket + pub const MAX_POOL_FEES_PER_BUCKET: u32 = 100; } /// Listing of parachains we integrate with. diff --git a/libs/traits/Cargo.toml b/libs/traits/Cargo.toml index 3c275df45f..c2a5199914 100644 --- a/libs/traits/Cargo.toml +++ b/libs/traits/Cargo.toml @@ -19,6 +19,7 @@ scale-info = { version = "2.3.0", default-features = false, features = ["derive" sp-arithmetic = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.43" } sp-runtime = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.43" } sp-std = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.43" } +strum = { workspace = true, default-features = false } [dev-dependencies] cfg-mocks = { path = "../mocks" } @@ -42,6 +43,7 @@ std = [ "sp-std/std", "cfg-primitives/std", "scale-info/std", + "strum/std", ] try-runtime = [ "frame-support/try-runtime", diff --git a/libs/traits/src/benchmarking.rs b/libs/traits/src/benchmarking.rs index 0aa23c0f48..263e249936 100644 --- a/libs/traits/src/benchmarking.rs +++ b/libs/traits/src/benchmarking.rs @@ -10,6 +10,12 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +use parity_scale_codec::{Decode, Encode}; +use scale_info::TypeInfo; +use sp_std::{fmt::Debug, vec::Vec}; + +use crate::fee::PoolFeeBucket; + /// Benchmark utility to create pools pub trait PoolBenchmarkHelper { type PoolId; @@ -120,3 +126,22 @@ pub trait ForeignInvestmentBenchmarkHelper { pool_currency: Self::CurrencyId, ); } + +/// Benchmark utility for adding pool fees +pub trait PoolFeesBenchmarkHelper { + type PoolFeeInfo: Encode + Decode + Clone + TypeInfo + Debug; + type PoolId: Encode + Decode + Clone + TypeInfo + Debug; + + /// Generate n default fixed pool fees and return their info + fn get_pool_fee_infos(n: u32) -> Vec; + + /// Add the default fixed fee `n` times to the given pool and bucket pair + fn add_pool_fees(pool_id: Self::PoolId, bucket: PoolFeeBucket, n: u32); + + /// Get the fee info for a fixed pool fee which takes 1% of the NAV + fn get_default_fixed_fee_info() -> Self::PoolFeeInfo; + + /// Get the fee info for a chargeable pool fee which can be charged up to + /// 1000u128 per second + fn get_default_charged_fee_info() -> Self::PoolFeeInfo; +} diff --git a/libs/traits/src/fee.rs b/libs/traits/src/fee.rs new file mode 100644 index 0000000000..b53ffd6106 --- /dev/null +++ b/libs/traits/src/fee.rs @@ -0,0 +1,73 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). + +// Centrifuge 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 (see http://www.gnu.org/licenses). + +// Centrifuge 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. + +use frame_support::dispatch::{Decode, Encode, MaxEncodedLen, TypeInfo}; +use sp_runtime::DispatchResult; +use strum::{EnumCount, EnumIter}; + +/// The priority segregation of pool fees +/// +/// NOTE: Whenever a new variant is added, must bump +/// [cfg_primitives::MAX_FEES_PER_POOL]. +#[derive( + Debug, Encode, Decode, EnumIter, EnumCount, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone, Copy, +)] +pub enum PoolFeeBucket { + /// Fees that are charged first, before any redemptions, investments, + /// repayments or originations + Top, + // Future: AfterTranche(TrancheId) +} + +/// Trait to add fees to a pool +pub trait PoolFees { + type PoolId; + type FeeInfo; + + /// Add a new fee to the pool and bucket. + /// + /// NOTE: Assumes call permissions are separately checked beforehand. + fn add_fee(pool_id: Self::PoolId, bucket: PoolFeeBucket, fee: Self::FeeInfo) -> DispatchResult; + + /// Returns the maximum number of pool fees per bucket required for accurate + /// weights + fn get_max_fees_per_bucket() -> u32; + + /// Returns the current amount of active fees for the given pool and bucket + /// pair + fn get_pool_fee_bucket_count(pool: Self::PoolId, bucket: PoolFeeBucket) -> u32; +} + +/// Trait to prorate a fee amount to a rate or amount +pub trait FeeAmountProration { + /// Returns the prorated amount based on the NAV passed time period. + fn saturated_prorated_amount(&self, portfolio_valuation: Balance, period: Time) -> Balance; + + /// Returns the proratio rate based on the NAV and passed time period. + fn saturated_prorated_rate(&self, portfolio_valuation: Balance, period: Time) -> Rate; +} + +#[cfg(test)] +mod tests { + use strum::IntoEnumIterator; + + use super::*; + + #[test] + fn max_fees_per_pool() { + assert!( + cfg_primitives::MAX_POOL_FEES_PER_BUCKET + <= (cfg_primitives::MAX_FEES_PER_POOL * PoolFeeBucket::iter().count() as u32), + "Need to bump MAX_FEES_PER_POOL after adding variant(s) to PoolFeeBuckets" + ); + } +} diff --git a/libs/traits/src/lib.rs b/libs/traits/src/lib.rs index 0f7cd9e2e0..4c9548df70 100644 --- a/libs/traits/src/lib.rs +++ b/libs/traits/src/lib.rs @@ -53,6 +53,7 @@ pub mod rewards; #[cfg(feature = "runtime-benchmarks")] /// Traits related to benchmarking tooling. pub mod benchmarking; +pub mod fee; /// A trait used for loosely coupling the claim pallet with a reward mechanism. /// @@ -176,6 +177,7 @@ pub trait PoolMutate { type MaxTranches: Get; type TrancheInput: Encode + Decode + Clone + TypeInfo + Debug + PartialEq; type PoolChanges: Encode + Decode + Clone + TypeInfo + Debug + PartialEq + MaxEncodedLen; + type PoolFeeInput: Encode + Decode + Clone + TypeInfo + Debug; fn create( admin: AccountId, @@ -184,6 +186,7 @@ pub trait PoolMutate { tranche_inputs: Vec, currency: Self::CurrencyId, max_reserve: Self::Balance, + pool_fees: Vec, ) -> DispatchResult; fn update(pool_id: PoolId, changes: Self::PoolChanges) -> Result; @@ -624,6 +627,25 @@ pub trait StatusNotificationHook { fn notify_status_change(id: Self::Id, status: Self::Status) -> Result<(), Self::Error>; } +/// Trait to signal an epoch transition. +pub trait EpochTransitionHook { + type Balance; + type PoolId; + type Time; + type Error; + + /// Hook into the closing of an epoch + fn on_closing_mutate_reserve( + pool_id: Self::PoolId, + assets_under_management: Self::Balance, + reserve: &mut Self::Balance, + ) -> Result<(), Self::Error>; + + /// Hook into the execution of an epoch before any investment and + /// redemption fulfillments + fn on_execution_pre_fulfillments(pool_id: Self::PoolId) -> Result<(), Self::Error>; +} + /// Trait to synchronously provide a currency conversion estimation for foreign /// currencies into/from pool currencies. pub trait IdentityCurrencyConversion { diff --git a/libs/types/Cargo.toml b/libs/types/Cargo.toml index 5530965abf..02a4720f41 100644 --- a/libs/types/Cargo.toml +++ b/libs/types/Cargo.toml @@ -15,7 +15,7 @@ bitflags = { version = "1.3", default-features = false } hex-literal = { version = "0.3.4", default-features = false } parity-scale-codec = { version = "3.0.0", features = ["derive"], default-features = false } scale-info = { version = "2.3.0", default-features = false, features = ["derive"] } -serde = { version = "1.0.119" } +serde = { workspace = true } # substrate dependencies frame-support = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.43" } diff --git a/libs/types/src/ids.rs b/libs/types/src/ids.rs index 835b323465..3e620d6913 100644 --- a/libs/types/src/ids.rs +++ b/libs/types/src/ids.rs @@ -36,6 +36,7 @@ pub const NFT_SALES_PALLET_ID: PalletId = PalletId(*b"pal/nfts"); pub const STAKE_POT_PALLET_ID: PalletId = PalletId(*b"PotStake"); pub const BLOCK_REWARDS_PALLET_ID: PalletId = PalletId(*b"cfg/blrw"); pub const LIQUIDITY_REWARDS_PALLET_ID: PalletId = PalletId(*b"cfg/lqrw"); +pub const POOL_FEES_PALLET_ID: PalletId = PalletId(*b"cfg/plfs"); // Other ids pub const CHAIN_BRIDGE_HASH_ID: [u8; 13] = *b"cent_nft_hash"; diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index e607ccc3d3..c221ca6ffa 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -28,6 +28,7 @@ pub mod oracles; pub mod orders; pub mod permissions; pub mod pools; +pub mod portfolio; pub mod time; pub mod tokens; pub mod xcm; diff --git a/libs/types/src/pools.rs b/libs/types/src/pools.rs index 4b8c52c9a2..02c8e7e8ed 100644 --- a/libs/types/src/pools.rs +++ b/libs/types/src/pools.rs @@ -10,10 +10,14 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +use cfg_traits::{fee::FeeAmountProration, Seconds}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; +use sp_arithmetic::FixedPointOperand; use sp_runtime::{traits::Get, BoundedVec, RuntimeDebug}; +use crate::fixed_point::FixedPointNumberExtension; + #[derive(Debug, Encode, PartialEq, Eq, Decode, Clone, TypeInfo, MaxEncodedLen)] pub struct TrancheMetadata where @@ -37,3 +41,309 @@ pub enum PoolRegistrationStatus { Registered, Unregistered, } + +/// The dynamic representation of a pool fee, its editor and destination +/// address. +/// +/// The pending and disbursement fee amounts are frequently updated based on the +/// positive NAV. +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub struct PoolFee { + /// Account that the fees are sent to + pub destination: AccountId, + + /// Account that can update this fee + pub editor: PoolFeeEditor, + + /// Amount of fees that can be charged + pub amounts: FeeAmounts, + + /// The identifier + pub id: FeeId, +} + +/// The static representation of a pool fee used for creation. +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub struct PoolFeeInfo { + /// Account that the fees are sent to + pub destination: AccountId, + + /// Account that can update this fee + pub editor: PoolFeeEditor, + + /// Amount of fees that can be charged + pub fee_type: PoolFeeType, +} + +impl PoolFee> +where + Balance: Default, +{ + pub fn from_info(fee: PoolFeeInfo, fee_id: FeeId) -> Self { + let payable = match fee.fee_type { + PoolFeeType::ChargedUpTo { .. } => PayableFeeAmount::UpTo(Balance::default()), + PoolFeeType::Fixed { .. } => PayableFeeAmount::AllPending, + }; + let amount = PoolFeeAmounts { + payable, + fee_type: fee.fee_type, + pending: Balance::default(), + disbursement: Balance::default(), + }; + + Self { + amounts: amount, + destination: fee.destination, + editor: fee.editor, + id: fee_id, + } + } +} + +/// The editor enum of pool fees +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub enum PoolFeeEditor { + /// Can only be changed by Root (e.g. Treasury fee) + Root, + /// Can only be changed by the encapsulated account + Account(AccountId), +} + +impl From> for Option { + fn from(editor: PoolFeeEditor) -> Option { + match editor { + PoolFeeEditor::Account(acc) => Some(acc), + _ => None, + } + } +} + +/// The static fee amount wrapper type +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub enum PoolFeeType { + /// A fixed fee is deducted automatically every epoch + Fixed { limit: PoolFeeAmount }, + + /// A fee can be charged up to a limit, paid every epoch + ChargedUpTo { limit: PoolFeeAmount }, +} + +/// The pending fee amount wrapper type. The `pending`, `disbursement` and +/// `payable` fields are updated on each NAV update, the `fee_type` is static. +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub struct PoolFeeAmounts { + /// The static fee type + pub fee_type: PoolFeeType, + /// The dynamic pending amount which represents outstanding fee amounts + /// which could not be paid. This can happen if + /// * Either the reserve is insufficient; or + /// * In case of a charged fee: If more was charged than can be paid. + pub pending: Balance, + /// The amount which will be paid during epoch closing. It is always ensured + /// that the reserve is sufficient for the sum of all fees' disbursement + /// amounts. + pub disbursement: Balance, + /// The maximum payable fee amount which is only used for charged fees. + /// Necessary to determine how much can be paid if the nothing or an excess + /// was charged. + pub payable: PayableFeeAmount, +} + +/// The payable fee amount representation which is either +/// * `AllPending` if the fee is not chargeable; or +/// * `UpTo(amount)` if the fee is chargeable. The `amount` reflects the max +/// payable amount at the time of the calculation. The disbursement of such +/// fee is the minimum of pending and payable. +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub enum PayableFeeAmount { + AllPending, + UpTo(Balance), +} + +impl PoolFeeAmounts { + pub fn limit(&self) -> &PoolFeeAmount { + match &self.fee_type { + PoolFeeType::Fixed { limit } | PoolFeeType::ChargedUpTo { limit } => limit, + } + } +} + +/// The static fee amount +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +pub enum PoolFeeAmount { + /// The relative amount dependent on the AssetsUnderManagement valuation + ShareOfPortfolioValuation(Rate), + /// The absolute amount per second + AmountPerSecond(Balance), +} + +impl FeeAmountProration for PoolFeeAmount +where + Rate: FixedPointNumberExtension, + Balance: From + FixedPointOperand + sp_std::ops::Div, +{ + fn saturated_prorated_amount(&self, portfolio_valuation: Balance, period: Seconds) -> Balance { + match self { + PoolFeeAmount::ShareOfPortfolioValuation(_) => { + let proration: Rate = + >::saturated_prorated_rate( + self, + portfolio_valuation, + period, + ); + proration.saturating_mul_int(portfolio_valuation) + } + PoolFeeAmount::AmountPerSecond(amount) => amount.saturating_mul(period.into()), + } + } + + fn saturated_prorated_rate(&self, portfolio_valuation: Balance, period: Seconds) -> Rate { + match self { + PoolFeeAmount::ShareOfPortfolioValuation(rate) => { + saturated_rate_proration(*rate, period) + } + PoolFeeAmount::AmountPerSecond(_) => { + let prorated_amount: Balance = >::saturated_prorated_amount( + self, portfolio_valuation, period + ); + Rate::saturating_from_rational(prorated_amount, portfolio_valuation) + } + } + } +} + +/// Converts an annual balance amount into its proratio based on the given +/// period duration. +pub fn saturated_balance_proration< + Balance: From + FixedPointOperand + sp_std::ops::Div, +>( + annual_amount: Balance, + period: Seconds, +) -> Balance { + let amount = annual_amount.saturating_mul(period.into()); + amount.div(cfg_primitives::SECONDS_PER_YEAR.into()) +} + +/// Converts an annual rate into its proratio based on the given +/// period duration. +pub fn saturated_rate_proration( + annual_rate: Rate, + period: Seconds, +) -> Rate { + let rate = annual_rate.saturating_mul(Rate::saturating_from_integer::(period)); + + rate.saturating_div_ceil(&Rate::saturating_from_integer::( + cfg_primitives::SECONDS_PER_YEAR, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod saturated_proration { + use cfg_primitives::SECONDS_PER_YEAR; + use sp_arithmetic::traits::{One, Zero}; + + use super::*; + use crate::fixed_point::Rate; + + type Balance = u128; + + #[test] + fn balance_zero() { + assert_eq!( + saturated_balance_proration::(SECONDS_PER_YEAR.into(), 0), + 0 + ); + assert_eq!( + saturated_balance_proration::(0u128, SECONDS_PER_YEAR), + 0 + ); + assert_eq!( + saturated_balance_proration::((SECONDS_PER_YEAR - 1).into(), 1), + 0 + ); + assert_eq!( + saturated_balance_proration::(1u128, SECONDS_PER_YEAR - 1), + 0 + ); + } + + #[test] + fn balance_one() { + assert_eq!( + saturated_balance_proration::(SECONDS_PER_YEAR.into(), 1), + 1u128 + ); + assert_eq!( + saturated_balance_proration::(1u128, SECONDS_PER_YEAR), + 1u128 + ); + } + #[test] + fn balance_overflow() { + assert_eq!( + saturated_balance_proration::(u128::MAX, u64::MAX), + u128::MAX / u128::from(SECONDS_PER_YEAR) + ); + } + + #[test] + fn rate_zero() { + assert_eq!( + saturated_rate_proration::(Rate::from_integer(SECONDS_PER_YEAR.into()), 0), + Rate::zero() + ); + assert_eq!( + saturated_rate_proration::(Rate::zero(), SECONDS_PER_YEAR), + Rate::zero() + ); + assert!( + saturated_rate_proration::( + Rate::from_integer((SECONDS_PER_YEAR - 1).into()), + 1 + ) > Rate::zero() + ); + assert!( + saturated_rate_proration::(Rate::one(), SECONDS_PER_YEAR - 1) > Rate::zero() + ); + } + + #[test] + fn rate_one() { + assert_eq!( + saturated_rate_proration::(Rate::from_integer(SECONDS_PER_YEAR.into()), 1), + Rate::one() + ); + assert_eq!( + saturated_rate_proration::(Rate::one(), SECONDS_PER_YEAR), + Rate::one() + ); + } + #[test] + fn rate_overflow() { + let left_bound = Rate::from_integer(10790); + let right_bound = Rate::from_integer(10791); + + let rate = saturated_rate_proration::( + Rate::from_integer(u128::from(u128::MAX / 10u128.pow(27))), + 1, + ); + assert!(left_bound < rate); + assert!(rate < right_bound); + + assert!(saturated_rate_proration::(Rate::one(), u64::MAX) > left_bound); + assert!(saturated_rate_proration::(Rate::one(), u64::MAX) < right_bound); + + assert!(saturated_rate_proration::(Rate::from_integer(2), u64::MAX) > left_bound); + assert!( + saturated_rate_proration::(Rate::from_integer(2), u64::MAX) < right_bound + ); + } + } +} diff --git a/pallets/loans/src/types/portfolio.rs b/libs/types/src/portfolio.rs similarity index 76% rename from pallets/loans/src/types/portfolio.rs rename to libs/types/src/portfolio.rs index eac15e653b..ad97a05b3e 100644 --- a/pallets/loans/src/types/portfolio.rs +++ b/libs/types/src/portfolio.rs @@ -1,3 +1,15 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). +// +// This file is part of the Centrifuge chain project. +// Centrifuge 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 (see http://www.gnu.org/licenses). +// Centrifuge 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. + use cfg_traits::{Seconds, TimeAsSecs}; use frame_support::{traits::Get, BoundedVec, RuntimeDebug}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; @@ -8,13 +20,24 @@ use sp_runtime::{ }; use sp_std::{cmp::Ordering, marker::PhantomData, vec::Vec}; -// Portfolio valuation information. -// It will be updated on these scenarios: -// 1. When we are calculating portfolio valuation for a pool. -// 2. When there is borrow or repay or write off on a loan under this pool -// So the portfolio valuation could be: -// - Approximate when current time != last_updated -// - Exact when current time == last_updated +/// Portfolio valuation information. +/// +/// The total NAV is based on the reserve, the assets under management (AUM) and +/// pool fees: +/// +/// ```ignore +/// NAV = PoolReserve + AUM - PoolFees +/// ``` +/// +/// It will be updated on these scenarios: +/// 1. When we are calculating portfolio valuation for a pool. +/// 2. When there is borrow or repay or write off on a loan under this pool. +/// This updates the positive part (assets under management, AUM). +/// 3. When pool fee disbursement is prepared. This updates the negative part +/// which is passed on the AUM of the previous epoch. +/// So the portfolio valuation could be: +/// - Approximate when current time != last_updated +/// - Exact when current time == last_updated #[derive(Encode, Decode, Clone, TypeInfo, MaxEncodedLen)] #[scale_info(skip_type_params(MaxElems))] pub struct PortfolioValuation> { @@ -143,7 +166,7 @@ where pub enum PortfolioValuationUpdateType { /// Portfolio Valuation was fully recomputed to an exact value Exact, - /// Portfolio Valuation was updated inexactly based on loan status changes + /// Portfolio Valuation was updated inexactly based on status changes Inexact, } diff --git a/pallets/block-rewards/src/weights.rs b/pallets/block-rewards/src/weights.rs index e757f22464..bf9d239564 100644 --- a/pallets/block-rewards/src/weights.rs +++ b/pallets/block-rewards/src/weights.rs @@ -42,20 +42,20 @@ pub struct SubstrateWeight(PhantomData); impl WeightInfo for SubstrateWeight { fn claim_reward() -> Weight { Weight::from_parts(49_000_000, 0) - .saturating_add(T::DbWeight::get().reads(5 as u64)) - .saturating_add(T::DbWeight::get().writes(3 as u64)) + .saturating_add(T::DbWeight::get().reads(5)) + .saturating_add(T::DbWeight::get().writes(3)) } fn set_collator_reward() -> Weight { Weight::from_parts(8_000_000, 0) - .saturating_add(T::DbWeight::get().reads(1 as u64)) - .saturating_add(T::DbWeight::get().writes(1 as u64)) + .saturating_add(T::DbWeight::get().reads(1)) + .saturating_add(T::DbWeight::get().writes(1)) } fn set_total_reward() -> Weight { Weight::from_parts(9_000_000, 0) - .saturating_add(T::DbWeight::get().reads(2 as u64)) - .saturating_add(T::DbWeight::get().writes(1 as u64)) + .saturating_add(T::DbWeight::get().reads(2)) + .saturating_add(T::DbWeight::get().writes(1)) } } @@ -63,19 +63,19 @@ impl WeightInfo for SubstrateWeight { impl WeightInfo for () { fn claim_reward() -> Weight { Weight::from_parts(49_000_000, 0) - .saturating_add(RocksDbWeight::get().reads(5 as u64)) - .saturating_add(RocksDbWeight::get().writes(3 as u64)) + .saturating_add(RocksDbWeight::get().reads(5)) + .saturating_add(RocksDbWeight::get().writes(3)) } fn set_collator_reward() -> Weight { Weight::from_parts(8_000_000, 0) - .saturating_add(RocksDbWeight::get().reads(1 as u64)) - .saturating_add(RocksDbWeight::get().writes(1 as u64)) + .saturating_add(RocksDbWeight::get().reads(1)) + .saturating_add(RocksDbWeight::get().writes(1)) } fn set_total_reward() -> Weight { Weight::from_parts(9_000_000, 0) - .saturating_add(RocksDbWeight::get().reads(2 as u64)) - .saturating_add(RocksDbWeight::get().writes(1 as u64)) + .saturating_add(RocksDbWeight::get().reads(2)) + .saturating_add(RocksDbWeight::get().writes(1)) } } diff --git a/pallets/keystore/src/benchmarking.rs b/pallets/keystore/src/benchmarking.rs index e0e46253d1..23e93e22bb 100644 --- a/pallets/keystore/src/benchmarking.rs +++ b/pallets/keystore/src/benchmarking.rs @@ -103,7 +103,7 @@ fn build_test_keys(n: u32) -> Vec> { }); } - return keys; + keys } impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Runtime,); diff --git a/pallets/keystore/src/lib.rs b/pallets/keystore/src/lib.rs index 46da73c65b..9d17ef0fc2 100644 --- a/pallets/keystore/src/lib.rs +++ b/pallets/keystore/src/lib.rs @@ -161,7 +161,7 @@ pub mod pallet { #[pallet::call] impl Pallet { /// Add keys to the storages. - #[pallet::weight(T::WeightInfo::add_keys(T::MaxKeys::get() as u32))] + #[pallet::weight(T::WeightInfo::add_keys(T::MaxKeys::get()))] #[pallet::call_index(0)] pub fn add_keys(origin: OriginFor, keys: Vec>) -> DispatchResult { let account_id = ensure_signed(origin)?; @@ -182,7 +182,7 @@ pub mod pallet { } /// Revoke keys with specified purpose. - #[pallet::weight(T::WeightInfo::revoke_keys(T::MaxKeys::get() as u32))] + #[pallet::weight(T::WeightInfo::revoke_keys(T::MaxKeys::get()))] #[pallet::call_index(1)] pub fn revoke_keys( origin: OriginFor, diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index aa288abf6c..de9d8bcf98 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -25,7 +25,7 @@ cfg-traits = { path = "../../libs/traits", default-features = false } cfg-types = { path = "../../libs/types", default-features = false } orml-traits = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.43" } -strum = { version = "0.24", default-features = false, features = ["derive"] } +strum = { workspace = true } # Optionals for benchmarking frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.43" } diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 12e47079ac..0802da14e7 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -78,6 +78,7 @@ pub mod pallet { use cfg_types::{ adjustments::Adjustment, permissions::{PermissionScope, PoolRole, Role}, + portfolio::{self, InitialPortfolioValuation, PortfolioValuationUpdateType}, }; use entities::{ changes::{Change, LoanMutation}, @@ -104,7 +105,6 @@ pub mod pallet { use types::{ self, policy::{self, WriteOffRule, WriteOffStatus}, - portfolio::{self, InitialPortfolioValuation, PortfolioValuationUpdateType}, BorrowLoanError, CloseLoanError, CreateLoanError, MutationError, RepayLoanError, WrittenOffError, }; @@ -128,7 +128,7 @@ pub mod pallet { /// Represent a runtime change type RuntimeChange: From> + TryInto>; - /// Identify a curreny. + /// Identify a currency. type CurrencyId: Parameter + Copy + MaxEncodedLen; /// Identify a non fungible collection @@ -1188,6 +1188,7 @@ pub mod pallet { } // TODO: This implementation can be cleaned once #908 be solved + // TODO: Check with team about state of comment impl PoolNAV for Pallet { type ClassId = T::ItemId; type RuntimeOrigin = T::RuntimeOrigin; @@ -1202,7 +1203,7 @@ pub mod pallet { } fn initialise(_: OriginFor, _: T::PoolId, _: T::ItemId) -> DispatchResult { - // This Loans implementation does not need to initialize explicitally. + // This Loans implementation does not need to initialize explicitly. Ok(()) } } diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index 3246293f64..729f54b0ec 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -23,7 +23,6 @@ use sp_runtime::{ }; pub mod policy; -pub mod portfolio; pub mod valuation; /// Error related to loan creation diff --git a/pallets/pool-fees/Cargo.toml b/pallets/pool-fees/Cargo.toml new file mode 100644 index 0000000000..9d8abdee39 --- /dev/null +++ b/pallets/pool-fees/Cargo.toml @@ -0,0 +1,84 @@ +[package] +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +name = "pallet-pool-fees" +description = "Pool Fees Pallet" +version = "0.0.1" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dev-dependencies] +cfg-mocks = { workspace = true, default-features = true } +cfg-test-utils = { path = "../../libs/test-utils", default-features = true } +orml-tokens = { workspace = true, default-features = true } +orml-traits = { workspace = true, default-features = true } +pallet-balances = { workspace = true, default-features = true } +pallet-timestamp = { workspace = true, default-features = true } +rand = "0.8.5" + +[dependencies] +log = { workspace = true } +parity-scale-codec = { workspace = true } +scale-info = { workspace = true, features = ["derive"] } +strum = { workspace = true } + +frame-benchmarking.workspace = true +frame-support.workspace = true +frame-system.workspace = true +sp-arithmetic.workspace = true +sp-core.workspace = true +sp-io.workspace = true +sp-runtime.workspace = true +sp-std.workspace = true + +cfg-primitives.workspace = true +cfg-traits.workspace = true +cfg-types.workspace = true + +[features] +default = ["std"] +runtime-benchmarks = [ + "cfg-primitives/runtime-benchmarks", + "cfg-traits/runtime-benchmarks", + "cfg-types/runtime-benchmarks", + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-timestamp/runtime-benchmarks", + "sp-runtime/runtime-benchmarks", +] +std = [ + "cfg-mocks/std", + "cfg-primitives/std", + "cfg-traits/std", + "cfg-types/std", + "parity-scale-codec/std", + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", + "log/std", + "orml-tokens/std", + "orml-traits/std", + "pallet-balances/std", + "scale-info/std", + "sp-core/std", + "sp-io/std", + "sp-runtime/std", + "sp-arithmetic/std", + "sp-std/std", + "strum/std", +] +try-runtime = [ + "cfg-primitives/try-runtime", + "cfg-traits/try-runtime", + "cfg-types/try-runtime", + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-timestamp/try-runtime", + "sp-runtime/try-runtime", +] diff --git a/pallets/pool-fees/src/benchmarking.rs b/pallets/pool-fees/src/benchmarking.rs new file mode 100644 index 0000000000..710af27270 --- /dev/null +++ b/pallets/pool-fees/src/benchmarking.rs @@ -0,0 +1,169 @@ +// Copyright 2021 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge Chain project. + +// Centrifuge 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 (see http://www.gnu.org/licenses). + +// Centrifuge 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. + +use cfg_traits::{ + benchmarking::{PoolBenchmarkHelper, PoolFeesBenchmarkHelper}, + changes::ChangeGuard, + fee::{PoolFeeBucket, PoolFees as _}, +}; +use cfg_types::pools::PoolFeeEditor; +use frame_benchmarking::v2::*; +use frame_support::{assert_ok, dispatch::RawOrigin}; + +use super::*; +use crate::{types::Change, Pallet as PoolFees}; + +pub(crate) const CHARGE_AMOUNT: u128 = 1_000_000_000_000_000_000; +pub(crate) const ACCOUNT_INDEX: u32 = 1_234; +pub(crate) const ACCOUNT_SEED: u32 = 5_678; + +#[benchmarks( + where + T::PoolId: Default, + T::ChangeGuard: PoolBenchmarkHelper, + T::Balance: From, + T::FeeId: From, + )] +mod benchmarks { + use super::*; + + #[benchmark] + fn propose_new_fee() -> Result<(), BenchmarkError> { + let pool_admin: T::AccountId = whitelisted_caller(); + T::ChangeGuard::bench_create_pool(T::PoolId::default(), &pool_admin); + + let signer = RawOrigin::Signed(pool_admin); + + #[extrinsic_call] + propose_new_fee( + signer, + T::PoolId::default(), + PoolFeeBucket::Top, + PoolFees::::get_default_fixed_fee_info(), + ); + + Ok(()) + } + + #[benchmark] + fn apply_new_fee(n: Linear<1, 99>) -> Result<(), BenchmarkError> { + benchmark_setup::(n); + + let change_id = T::ChangeGuard::note( + T::PoolId::default(), + Change::::AppendFee( + PoolFeeBucket::Top, + PoolFees::::get_default_fixed_fee_info(), + ) + .into(), + ) + .unwrap(); + + let signer = RawOrigin::Signed(account::("signer", 2, 2)); + + #[extrinsic_call] + apply_new_fee(signer, T::PoolId::default(), change_id); + + Ok(()) + } + + #[benchmark] + fn remove_fee(n: Linear<1, 100>) -> Result<(), BenchmarkError> { + benchmark_setup::(n); + + let editor: T::AccountId = + as Into>>::into( + PoolFees::::get_default_fixed_fee_info().editor, + ) + .expect("Editor is AccountId32"); + let signer = RawOrigin::Signed(editor); + + #[extrinsic_call] + remove_fee(signer, n.into()); + + Ok(()) + } + + #[benchmark] + fn charge_fee(n: Linear<1, 99>) -> Result<(), BenchmarkError> { + benchmark_setup::(n); + assert_ok!(PoolFees::::add_fee( + T::PoolId::default(), + PoolFeeBucket::Top, + PoolFees::::get_default_charged_fee_info() + )); + + let signer = RawOrigin::Signed(PoolFees::::get_default_charged_fee_info().destination); + + #[extrinsic_call] + charge_fee(signer, (n + 1).into(), CHARGE_AMOUNT.into()); + + Ok(()) + } + + #[benchmark] + fn uncharge_fee(n: Linear<1, 99>) -> Result<(), BenchmarkError> { + benchmark_setup::(n); + assert_ok!(PoolFees::::add_fee( + T::PoolId::default(), + PoolFeeBucket::Top, + PoolFees::::get_default_charged_fee_info() + )); + + let signer = RawOrigin::Signed(PoolFees::::get_default_charged_fee_info().destination); + + assert_ok!(PoolFees::::charge_fee( + signer.clone().into(), + (n + 1).into(), + CHARGE_AMOUNT.into() + )); + + #[extrinsic_call] + uncharge_fee(signer, (n + 1).into(), CHARGE_AMOUNT.into()); + + Ok(()) + } + + #[benchmark] + fn update_portfolio_valuation(n: Linear<1, 100>) -> Result<(), BenchmarkError> { + benchmark_setup::(n); + + let signer = RawOrigin::Signed(account::("signer", 2, 2)); + + #[extrinsic_call] + update_portfolio_valuation(signer, T::PoolId::default()); + + Ok(()) + } + + impl_benchmark_test_suite!( + PoolFees, + crate::mock::ExtBuilder::default().build(), + crate::mock::Runtime + ); +} + +fn benchmark_setup(n: u32) +where + T::PoolId: Default, + T::ChangeGuard: PoolBenchmarkHelper, +{ + #[cfg(test)] + mock::init_mocks(); + + let pool_admin: T::AccountId = whitelisted_caller(); + let pool_id = T::PoolId::default(); + T::ChangeGuard::bench_create_pool(pool_id, &pool_admin); + + as PoolFeesBenchmarkHelper>::add_pool_fees(pool_id, PoolFeeBucket::Top, n); +} diff --git a/pallets/pool-fees/src/lib.rs b/pallets/pool-fees/src/lib.rs new file mode 100644 index 0000000000..df08da9781 --- /dev/null +++ b/pallets/pool-fees/src/lib.rs @@ -0,0 +1,886 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). + +// Centrifuge 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 (see http://www.gnu.org/licenses). + +// Centrifuge 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. + +#![cfg_attr(not(feature = "std"), no_std)] + +pub use pallet::*; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; +pub mod types; + +#[frame_support::pallet] +pub mod pallet { + #[cfg(feature = "runtime-benchmarks")] + use cfg_traits::benchmarking::PoolFeesBenchmarkHelper; + use cfg_traits::{ + changes::ChangeGuard, + fee::{FeeAmountProration, PoolFeeBucket, PoolFees}, + EpochTransitionHook, PoolInspect, PoolNAV, PoolReserve, PreConditions, Seconds, TimeAsSecs, + }; + use cfg_types::{ + pools::{ + PayableFeeAmount, PoolFee, PoolFeeAmount, PoolFeeAmounts, PoolFeeEditor, PoolFeeInfo, + PoolFeeType, + }, + portfolio, + portfolio::{InitialPortfolioValuation, PortfolioValuationUpdateType}, + }; + use frame_support::{ + pallet_prelude::*, + traits::{ + fungibles::{Inspect, Mutate}, + tokens, + tokens::Preservation, + }, + weights::Weight, + PalletId, + }; + use frame_system::pallet_prelude::*; + use parity_scale_codec::HasCompact; + #[cfg(feature = "runtime-benchmarks")] + use sp_arithmetic::fixed_point::FixedPointNumber; + use sp_arithmetic::{ + traits::{EnsureAdd, EnsureAddAssign, EnsureSub, EnsureSubAssign, One, Saturating, Zero}, + ArithmeticError, FixedPointOperand, + }; + use sp_runtime::{traits::AccountIdConversion, SaturatedConversion}; + use sp_std::vec::Vec; + use strum::IntoEnumIterator; + + use super::*; + use crate::types::Change; + + pub type PoolFeeInfoOf = PoolFeeInfo< + ::AccountId, + ::Balance, + ::Rate, + >; + + pub type PoolFeeOf = PoolFee< + ::AccountId, + ::FeeId, + PoolFeeAmounts<::Balance, ::Rate>, + >; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + /// The identifier of a particular fee + type FeeId: Parameter + + Member + + Default + + TypeInfo + + MaxEncodedLen + + Copy + + EnsureAdd + + One + + Ord; + + /// The source of truth for the balance of accounts + type Balance: tokens::Balance + FixedPointOperand + From; + + /// The currency type of transferrable tokens + type CurrencyId: Parameter + Member + Copy + TypeInfo + MaxEncodedLen; + + /// The pool id type required for the investment identifier + type PoolId: Member + Parameter + Default + Copy + HasCompact + MaxEncodedLen; + + /// Type for price ratio for cost of incoming currency relative to + /// outgoing + type Rate: Parameter + + Member + + cfg_types::fixed_point::FixedPointNumberExtension + + MaybeSerializeDeserialize + + TypeInfo + + MaxEncodedLen; + + /// The type for handling transfers, burning and minting of + /// multi-assets. + type Tokens: Mutate + + Inspect; + + /// The source of truth for runtime changes. + type RuntimeChange: From> + TryInto>; + + /// Used to notify the runtime about changes that require special + /// treatment. + type ChangeGuard: ChangeGuard< + PoolId = Self::PoolId, + ChangeId = Self::Hash, + Change = Self::RuntimeChange, + >; + + /// The source of truth for pool existence and provider for pool reserve + /// operations required to withdraw fees. + type PoolReserve: PoolReserve< + Self::AccountId, + Self::CurrencyId, + Balance = Self::Balance, + PoolId = Self::PoolId, + >; + + /// Used to verify pool admin permissions + type IsPoolAdmin: PreConditions<(Self::AccountId, Self::PoolId), Result = bool>; + + /// The pool fee bound per bucket. If multiplied with the number of + /// bucket variants, this yields the max number of fees per pool. + type MaxPoolFeesPerBucket: Get; + + /// The upper bound for the total number of fees per pool. + type MaxFeesPerPool: Get; + + /// Identifier of this pallet used as an account which temporarily + /// stores disbursing fees in between closing and executing an epoch. + #[pallet::constant] + type PalletId: Get; + + /// Fetching method for the time of the current block + type Time: TimeAsSecs; + + // TODO: Enable after creating benchmarks + // type WeightInfo: WeightInfo; + } + + /// Maps a pool to their corresponding fee ids with [PoolFeeBucket] + /// granularity. + /// + /// Lifetime of a storage entry: Forever, inherited from pool lifetime. + #[pallet::storage] + pub type FeeIds = StorageDoubleMap< + _, + Blake2_128Concat, + T::PoolId, + Blake2_128Concat, + PoolFeeBucket, + BoundedVec, + ValueQuery, + >; + + /// Source of truth for the last created fee identifier. + /// + /// Lifetime: Forever. + #[pallet::storage] + pub type LastFeeId = StorageValue<_, T::FeeId, ValueQuery>; + + /// Maps a fee identifier to the corresponding pool and [PoolFeeBucket]. + /// + /// Lifetime of a storage entry: Forever, inherited from pool lifetime. + #[pallet::storage] + pub type FeeIdsToPoolBucket = + StorageMap<_, Blake2_128Concat, T::FeeId, (T::PoolId, PoolFeeBucket), OptionQuery>; + + /// Represents the active fees for a given pool id and fee bucket. For each + /// fee, the limit as well as pending, disbursement and payable amounts are + /// included. + /// + /// Lifetime of a storage entry: Forever, inherited from pool lifetime. + #[pallet::storage] + pub type ActiveFees = StorageDoubleMap< + _, + Blake2_128Concat, + T::PoolId, + Blake2_128Concat, + PoolFeeBucket, + BoundedVec, T::MaxPoolFeesPerBucket>, + ValueQuery, + >; + + /// Stores the (negative) portfolio valuation associated to each pool + /// derived from the pending fee amounts. + /// + /// Lifetime of a storage entry: Forever, inherited from pool lifetime. + #[pallet::storage] + pub(crate) type PortfolioValuation = StorageMap< + _, + Blake2_128Concat, + T::PoolId, + portfolio::PortfolioValuation, + ValueQuery, + InitialPortfolioValuation, + >; + + /// Stores the (positive) portfolio valuation associated to each pool + /// derived from the AUM of the previous epoch. + /// + /// Lifetime of a storage entry: Forever, inherited from pool lifetime. + #[pallet::storage] + pub(crate) type AssetsUnderManagement = + StorageMap<_, Blake2_128Concat, T::PoolId, T::Balance, ValueQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new pool fee was proposed. + Proposed { + pool_id: T::PoolId, + bucket: PoolFeeBucket, + fee: PoolFeeInfoOf, + }, + /// A previously proposed and approved pool fee was added. + Added { + pool_id: T::PoolId, + bucket: PoolFeeBucket, + fee_id: T::FeeId, + fee: PoolFeeInfoOf, + }, + /// A pool fee was removed. + Removed { + pool_id: T::PoolId, + bucket: PoolFeeBucket, + fee_id: T::FeeId, + }, + /// A pool fee was charged. + Charged { + fee_id: T::FeeId, + amount: T::Balance, + pending: T::Balance, + }, + /// A pool fee was uncharged. + Uncharged { + fee_id: T::FeeId, + amount: T::Balance, + pending: T::Balance, + }, + /// A pool fee was paid. + Paid { + fee_id: T::FeeId, + amount: T::Balance, + destination: T::AccountId, + }, + /// The portfolio valuation for a pool was updated. + PortfolioValuationUpdated { + pool_id: T::PoolId, + valuation: T::Balance, + update_type: PortfolioValuationUpdateType, + }, + } + + #[pallet::error] + pub enum Error { + /// A fee could not be found. + FeeNotFound, + /// A pool could not be found. + PoolNotFound, + /// Only the PoolAdmin can execute a given operation. + NotPoolAdmin, + /// The pool bucket has reached the maximum fees size. + MaxPoolFeesPerBucket, + /// The change id does not belong to a pool fees change. + ChangeIdNotPoolFees, + /// The fee can only be charged by the destination. + UnauthorizedCharge, + /// The fee can only be edited or removed by the editor. + UnauthorizedEdit, + /// Attempted to charge a fee of unchargeable type. + CannotBeCharged, + } + + #[pallet::call] + impl Pallet { + /// Propose to append a new fee to the given (pool, bucket) pair. + /// + /// Origin must be by pool admin. + #[pallet::call_index(0)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn propose_new_fee( + origin: OriginFor, + pool_id: T::PoolId, + bucket: PoolFeeBucket, + fee: PoolFeeInfoOf, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + ensure!( + T::PoolReserve::pool_exists(pool_id), + Error::::PoolNotFound + ); + ensure!( + T::IsPoolAdmin::check((who, pool_id)), + Error::::NotPoolAdmin + ); + + T::ChangeGuard::note(pool_id, Change::AppendFee(bucket, fee.clone()).into())?; + + Self::deposit_event(Event::::Proposed { + pool_id, + bucket, + fee, + }); + + Ok(()) + } + + /// Execute a successful fee append proposal for the given (pool, + /// bucket) pair. + /// + /// Origin unrestriced due to pre-check via proposal gate. + #[pallet::call_index(1)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn apply_new_fee( + origin: OriginFor, + pool_id: T::PoolId, + change_id: T::Hash, + ) -> DispatchResult { + ensure_signed(origin)?; + + ensure!( + T::PoolReserve::pool_exists(pool_id), + Error::::PoolNotFound + ); + let (bucket, fee) = Self::get_released_change(pool_id, change_id) + .map(|Change::AppendFee(bucket, fee)| (bucket, fee))?; + + Self::add_fee(pool_id, bucket, fee)?; + + Ok(()) + } + + /// Remove a fee. + /// + /// Origin must be the fee editor. + #[pallet::call_index(2)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn remove_fee(origin: OriginFor, fee_id: T::FeeId) -> DispatchResult { + let who = ensure_signed(origin)?; + + let fee = Self::get_active_fee(fee_id)?; + ensure!( + matches!(fee.editor, PoolFeeEditor::Account(account) if account == who), + Error::::UnauthorizedEdit + ); + Self::do_remove_fee(fee_id)?; + + Ok(()) + } + + /// Charge a fee. + /// + /// Origin must be the fee destination. + #[pallet::call_index(3)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn charge_fee( + origin: OriginFor, + fee_id: T::FeeId, + amount: T::Balance, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let pending = Self::mutate_active_fee(fee_id, |fee| { + ensure!( + fee.destination == who, + DispatchError::from(Error::::UnauthorizedCharge) + ); + + match fee.amounts.fee_type { + PoolFeeType::ChargedUpTo { .. } => { + fee.amounts.pending.ensure_add_assign(amount)?; + Ok(fee.amounts.pending) + } + _ => Err(DispatchError::from(Error::::CannotBeCharged)), + } + })?; + + Self::deposit_event(Event::::Charged { + fee_id, + amount, + pending, + }); + + Ok(()) + } + + /// Cancel a charged fee. + /// + /// Origin must be the fee destination. + #[pallet::call_index(4)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn uncharge_fee( + origin: OriginFor, + fee_id: T::FeeId, + amount: T::Balance, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let pending = Self::mutate_active_fee(fee_id, |fee| { + ensure!( + fee.destination == who, + DispatchError::from(Error::::UnauthorizedCharge) + ); + + match fee.amounts.fee_type { + PoolFeeType::ChargedUpTo { .. } => { + fee.amounts.pending.ensure_sub_assign(amount)?; + Ok(fee.amounts.pending) + } + _ => Err(DispatchError::from(Error::::CannotBeCharged)), + } + })?; + + Self::deposit_event(Event::::Uncharged { + fee_id, + amount, + pending, + }); + + Ok(()) + } + + /// Update the negative portfolio valuation via pending amounts of the + /// pool's active fees. Also updates the latter if the last update + /// happened in the past. + /// + /// NOTE: There can be fee amounts which are dependent on + /// AssetsUnderManagement. Therefore, we enforce this to have been + /// updated in the current timestamp. In the future, this coupling will + /// be handled by an accounting pallet. + #[pallet::call_index(5)] + #[pallet::weight(Weight::from_parts(10_000, 0))] + pub fn update_portfolio_valuation( + origin: OriginFor, + pool_id: T::PoolId, + ) -> DispatchResultWithPostInfo { + ensure_signed(origin)?; + + ensure!( + T::PoolReserve::pool_exists(pool_id), + Error::::PoolNotFound + ); + + let (_, _count) = + Self::update_portfolio_valuation_for_pool(pool_id, &mut T::Balance::zero())?; + + // Ok(Some(T::WeightInfo::update_portfolio_valuation(count)).into()) + Ok(Some(T::DbWeight::get().reads(1)).into()) + } + } + + impl Pallet { + /// The account ID of the pool fees. + /// + /// This actually does computation. If you need to keep using it, then + /// make sure you cache the value and only call this once. + pub fn account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Retrieve the specified fee from the list of active fees + pub fn get_active_fee(fee_id: T::FeeId) -> Result, DispatchError> { + Ok(FeeIdsToPoolBucket::::get(fee_id) + .and_then(|(pool_id, bucket)| { + ActiveFees::::get(pool_id, bucket) + .into_iter() + .find(|fee| fee.id == fee_id) + }) + .ok_or(Error::::FeeNotFound)?) + } + + /// Mutate fee id entry in ActiveFees + fn mutate_active_fee( + fee_id: T::FeeId, + mut f: impl FnMut(&mut PoolFeeOf) -> Result, + ) -> Result { + let (pool_id, bucket) = + FeeIdsToPoolBucket::::get(fee_id).ok_or(Error::::FeeNotFound)?; + + ActiveFees::::mutate(pool_id, bucket, |fees| { + let pos = fees + .iter() + .position(|fee| fee.id == fee_id) + .ok_or(Error::::FeeNotFound)?; + + if let Some(fee) = fees.get_mut(pos) { + f(fee) + } else { + Ok(T::Balance::zero()) + } + }) + } + + /// Return the the last fee id and bump it for the next query + fn generate_fee_id() -> Result { + LastFeeId::::try_mutate(|last_fee_id| { + last_fee_id.ensure_add_assign(One::one())?; + Ok(*last_fee_id) + }) + } + + fn get_released_change( + pool_id: T::PoolId, + change_id: T::Hash, + ) -> Result, DispatchError> { + T::ChangeGuard::released(pool_id, change_id)? + .try_into() + .map_err(|_| Error::::ChangeIdNotPoolFees.into()) + } + + /// Transfer any due fees from the Pallet account to the corresponding + /// destination. The waterfall of fee payment follows the order of the + /// corresponding [PoolFeeBucket]. + pub(crate) fn pay_active_fees( + pool_id: T::PoolId, + bucket: PoolFeeBucket, + ) -> Result<(), DispatchError> { + let pool_currency = + T::PoolReserve::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; + + ActiveFees::::mutate(pool_id, bucket, |fees| { + for fee in fees.iter_mut() { + T::Tokens::transfer( + pool_currency, + &T::PalletId::get().into_account_truncating(), + &fee.destination, + fee.amounts.disbursement, + Preservation::Expendable, + )?; + + Self::deposit_event(Event::::Paid { + fee_id: fee.id, + amount: fee.amounts.disbursement, + destination: fee.destination.clone(), + }); + + fee.amounts.disbursement = T::Balance::zero(); + } + + Ok(()) + }) + } + + /// Update the pending, disbursement and payable fee amounts based on + /// the AUM and time difference since the last update. + /// + /// For each fee in the order of the waterfall, decrements the provided + /// `reserve` by the payable fee amount to determine disbursements. + /// Returns the final `reserve` amount. + pub(crate) fn update_active_fees( + pool_id: T::PoolId, + bucket: PoolFeeBucket, + reserve: &mut T::Balance, + assets_under_management: T::Balance, + epoch_duration: Seconds, + ) -> Result { + ActiveFees::::mutate(pool_id, bucket, |fees| { + for fee in fees.iter_mut() { + let limit = fee.amounts.limit(); + + // Determine payable amount since last update based on epoch duration + let epoch_amount = ::Balance, + ::Rate, + > as FeeAmountProration>::saturated_prorated_amount( + limit, + assets_under_management, + epoch_duration, + ); + + let fee_amount = match fee.amounts.payable { + PayableFeeAmount::UpTo(payable) => { + let payable_amount = payable.ensure_add(epoch_amount)?; + fee.amounts.payable = PayableFeeAmount::UpTo(payable_amount); + Ok(fee.amounts.pending.min(payable_amount)) + } + // NOTE: Implicitly assuming Fixed fee because of missing payable + PayableFeeAmount::AllPending => { + fee.amounts.pending.ensure_add_assign(epoch_amount)?; + Ok(fee.amounts.pending) + } + } + .map_err(|e: DispatchError| e)?; + + // Disbursement amount is limited by reserve + let disbursement = fee_amount.min(*reserve); + reserve.ensure_sub_assign(disbursement)?; + + // Update fee amounts + fee.amounts.pending.ensure_sub_assign(disbursement)?; + fee.amounts.disbursement.ensure_add_assign(disbursement)?; + if let PayableFeeAmount::UpTo(payable) = fee.amounts.payable { + fee.amounts.payable = + PayableFeeAmount::UpTo(payable.ensure_sub(disbursement)?) + }; + } + Ok::<(), DispatchError>(()) + })?; + + Ok(*reserve) + } + + /// Entirely remove a stored fee from the given pair of pool id and fee + /// bucket. + /// + /// NOTE: Assumes call permissions are separately checked beforehand. + fn do_remove_fee(fee_id: T::FeeId) -> Result<(), DispatchError> { + FeeIdsToPoolBucket::::mutate_exists(fee_id, |maybe_key| { + maybe_key + .as_ref() + .map(|(pool_id, bucket)| { + ActiveFees::::mutate(pool_id, bucket, |fees| { + let pos = fees + .iter() + .position(|fee| fee.id == fee_id) + .ok_or(Error::::FeeNotFound)?; + fees.remove(pos); + + Ok::<(), DispatchError>(()) + })?; + + FeeIds::::mutate(pool_id, bucket, |fee_ids| { + let pos = fee_ids + .iter() + .position(|id| id == &fee_id) + .ok_or(Error::::FeeNotFound)?; + fee_ids.remove(pos); + + Ok::<(T::PoolId, PoolFeeBucket), DispatchError>((*pool_id, *bucket)) + }) + }) + .transpose()? + .map(|(pool_id, bucket)| { + Self::deposit_event(Event::::Removed { + pool_id, + bucket, + fee_id, + }); + + Ok::<(), DispatchError>(()) + }); + + *maybe_key = None; + Ok::<(), DispatchError>(()) + })?; + + Ok(()) + } + + /// Update the NAV of the specified pool by incrementing each fee by the + /// payable epoch amount based on the fee configuration. As long as the + /// reserve is not empty, increments the disbursement amount of fees + /// following the waterfall. The reserve is sufficient, if all fees can + /// pe paid out by their outstanding payable amount. + /// + /// The NAV value is the sum all pending amounts which do not include + /// disbursements. Thus, if NAV update is called for an empty reserve, + /// the valuation is maximized at this point in time because each unit + /// of reserve reduces the NAV by reducing pending to disbursment + /// amounts. + /// ```ignore + /// NAV(PoolFees) = sum(pending_fee_amount) = sum(epoch_amount - disbursement) + /// ``` + fn update_portfolio_valuation_for_pool( + pool_id: T::PoolId, + reserve: &mut T::Balance, + ) -> Result<(T::Balance, u32), DispatchError> { + let fee_nav = PortfolioValuation::::get(pool_id); + let aum = AssetsUnderManagement::::get(pool_id); + let time_diff = T::Time::now().saturating_sub(fee_nav.last_updated()); + + for bucket in PoolFeeBucket::iter() { + // NOTE: Re-evaluate access to reserve after adding new bucket variants. Some + // should not reduce at this point in time. + Self::update_active_fees(pool_id, bucket, reserve, aum, time_diff)?; + } + + // Derive valuation from pending fee amounts + let values = PoolFeeBucket::iter() + .flat_map(|bucket| { + let fees = ActiveFees::::get(pool_id, bucket); + fees.into_iter().map(|fee| (fee.id, fee.amounts.pending)) + }) + .collect::>(); + + let portfolio = + portfolio::PortfolioValuation::from_values(T::Time::now(), values.clone())?; + let valuation = portfolio.value(); + PortfolioValuation::::insert(pool_id, portfolio); + + Self::deposit_event(Event::::PortfolioValuationUpdated { + pool_id, + valuation, + update_type: PortfolioValuationUpdateType::Exact, + }); + + Ok((valuation, values.len().saturated_into())) + } + } + + impl PoolFees for Pallet { + type FeeInfo = PoolFeeInfoOf; + type PoolId = T::PoolId; + + fn add_fee( + pool_id: Self::PoolId, + bucket: PoolFeeBucket, + fee: Self::FeeInfo, + ) -> Result<(), DispatchError> { + let fee_id = Self::generate_fee_id()?; + + FeeIds::::mutate(pool_id, bucket, |list| list.try_push(fee_id)) + .map_err(|_| Error::::MaxPoolFeesPerBucket)?; + ActiveFees::::mutate(pool_id, bucket, |list| { + list.try_push(PoolFeeOf::::from_info(fee.clone(), fee_id)) + }) + .map_err(|_| Error::::MaxPoolFeesPerBucket)?; + FeeIdsToPoolBucket::::insert(fee_id, (pool_id, bucket)); + + Self::deposit_event(Event::::Added { + pool_id, + bucket, + fee, + fee_id, + }); + + Ok(()) + } + + fn get_max_fees_per_bucket() -> u32 { + T::MaxPoolFeesPerBucket::get() + } + + fn get_pool_fee_bucket_count(pool: Self::PoolId, bucket: PoolFeeBucket) -> u32 { + ActiveFees::::get(pool, bucket).len().saturated_into() + } + } + + impl EpochTransitionHook for Pallet { + type Balance = T::Balance; + type Error = DispatchError; + type PoolId = T::PoolId; + type Time = Seconds; + + fn on_closing_mutate_reserve( + pool_id: Self::PoolId, + assets_under_management: Self::Balance, + reserve: &mut Self::Balance, + ) -> Result<(), Self::Error> { + // Determine pending fees and NAV based on last epoch's AUM + let res_pre_fees = *reserve; + Self::update_portfolio_valuation_for_pool(pool_id, reserve)?; + + // Set current AUM for next epoch's closing + AssetsUnderManagement::::insert(pool_id, assets_under_management); + + // Transfer disbursement amount from pool account to pallet sovereign account + let total_fee_amount = res_pre_fees.saturating_sub(*reserve); + if !total_fee_amount.is_zero() { + let pool_currency = + T::PoolReserve::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; + let pool_account = T::PoolReserve::account_for(pool_id); + + T::Tokens::transfer( + pool_currency, + &pool_account, + &T::PalletId::get().into_account_truncating(), + total_fee_amount, + Preservation::Expendable, + )?; + } + + Ok(()) + } + + fn on_execution_pre_fulfillments(pool_id: Self::PoolId) -> Result<(), Self::Error> { + Self::pay_active_fees(pool_id, PoolFeeBucket::Top)?; + + Ok(()) + } + } + + impl PoolNAV for Pallet { + type ClassId = (); + type RuntimeOrigin = T::RuntimeOrigin; + + fn nav(pool_id: T::PoolId) -> Option<(T::Balance, Seconds)> { + let portfolio = PortfolioValuation::::get(pool_id); + Some((portfolio.value(), portfolio.last_updated())) + } + + fn update_nav(pool_id: T::PoolId) -> Result { + Ok(Self::update_portfolio_valuation_for_pool(pool_id, &mut T::Balance::zero())?.0) + } + + fn initialise(_: OriginFor, _: T::PoolId, _: Self::ClassId) -> DispatchResult { + // This PoolFees implementation does not need to initialize explicitly. + Ok(()) + } + } + + #[cfg(feature = "runtime-benchmarks")] + impl PoolFeesBenchmarkHelper for Pallet { + type PoolFeeInfo = PoolFeeInfoOf; + type PoolId = T::PoolId; + + fn get_pool_fee_infos(n: u32) -> Vec { + (0..n).map(|_| Self::get_default_fixed_fee_info()).collect() + } + + fn add_pool_fees(pool_id: Self::PoolId, bucket: PoolFeeBucket, n: u32) { + let fee_infos = Self::get_pool_fee_infos(n); + + for fee_info in fee_infos { + frame_support::assert_ok!(Self::add_fee(pool_id, bucket, fee_info)); + } + } + + fn get_default_fixed_fee_info() -> Self::PoolFeeInfo { + let destination = frame_benchmarking::account::( + "fee destination", + benchmarking::ACCOUNT_INDEX, + benchmarking::ACCOUNT_SEED, + ); + let editor = frame_benchmarking::account::( + "fee editor", + benchmarking::ACCOUNT_INDEX + 1, + benchmarking::ACCOUNT_SEED + 1, + ); + + PoolFeeInfoOf:: { + destination, + editor: PoolFeeEditor::Account(editor), + fee_type: PoolFeeType::::Fixed { + limit: PoolFeeAmount::::ShareOfPortfolioValuation( + T::Rate::saturating_from_rational(1, 100), + ), + }, + } + } + + fn get_default_charged_fee_info() -> Self::PoolFeeInfo { + let destination = frame_benchmarking::account::( + "fee destination", + benchmarking::ACCOUNT_INDEX, + benchmarking::ACCOUNT_SEED, + ); + let editor = frame_benchmarking::account::( + "fee editor", + benchmarking::ACCOUNT_INDEX + 1, + benchmarking::ACCOUNT_SEED + 1, + ); + + PoolFeeInfoOf:: { + destination, + editor: PoolFeeEditor::Account(editor), + fee_type: PoolFeeType::::ChargedUpTo { + limit: PoolFeeAmount::::AmountPerSecond(1000u64.into()), + }, + } + } + } +} diff --git a/pallets/pool-fees/src/mock.rs b/pallets/pool-fees/src/mock.rs new file mode 100644 index 0000000000..b28f220d5b --- /dev/null +++ b/pallets/pool-fees/src/mock.rs @@ -0,0 +1,456 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). +// This file is part of Centrifuge chain project. + +// Centrifuge 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 (see http://www.gnu.org/licenses). + +// Centrifuge 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. + +use cfg_mocks::{ + pallet_mock_change_guard, pallet_mock_permissions, pallet_mock_pools, + pre_conditions::pallet as pallet_mock_pre_conditions, +}; +use cfg_primitives::{Balance, CollectionId, PoolFeeId, PoolId, TrancheId}; +use cfg_traits::{fee::PoolFeeBucket, PoolNAV}; +use cfg_types::{ + fixed_point::{Rate, Ratio}, + permissions::PermissionScope, + pools::{PayableFeeAmount, PoolFeeAmount, PoolFeeEditor, PoolFeeType}, + tokens::TrancheCurrency, +}; +use frame_support::{ + assert_ok, + pallet_prelude::ConstU32, + parameter_types, + traits::{ + fungibles::{Inspect, Mutate}, + ConstU128, ConstU16, ConstU64, + }, + PalletId, +}; +use sp_arithmetic::{traits::Zero, FixedPointNumber}; +use sp_core::H256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + DispatchError, +}; +use sp_std::vec::Vec; + +use crate::{ + pallet as pallet_pool_fees, pallet::AssetsUnderManagement, types::Change, ActiveFees, Event, + FeeIds, FeeIdsToPoolBucket, LastFeeId, PoolFeeInfoOf, PoolFeeOf, +}; + +pub const SECONDS: u64 = 1000; + +pub const ADMIN: AccountId = 1; +pub const EDITOR: AccountId = 2; +pub const DESTINATION: AccountId = 3; +pub const ANY: AccountId = 100; +pub const POOL_ACCOUNT: AccountId = 1000; + +pub const NOT_ADMIN: [AccountId; 3] = [EDITOR, DESTINATION, ANY]; +pub const NOT_EDITOR: [AccountId; 3] = [ADMIN, DESTINATION, ANY]; +pub const NOT_DESTINATION: [AccountId; 3] = [ADMIN, EDITOR, ANY]; + +pub const POOL: PoolId = 1; +pub const POOL_CURRENCY: CurrencyId = 42; +pub const CHANGE_ID: ChangeId = H256::repeat_byte(0x42); +pub const BUCKET: PoolFeeBucket = PoolFeeBucket::Top; + +pub const NAV: Balance = 1_000_000_000_000_000; + +pub const ERR_CHANGE_GUARD_RELEASE: DispatchError = + DispatchError::Other("ChangeGuard release disabled if not mocked via config_change_mocks"); + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; + +type Block = frame_system::mocking::MockBlock; +pub type AccountId = u64; +pub type CurrencyId = u32; +pub type ChangeId = H256; + +frame_support::construct_runtime!( + pub enum Runtime where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + MockTime: cfg_mocks::pallet_mock_time, + Balances: pallet_balances, + MockPools: pallet_mock_pools, + MockIsAdmin: pallet_mock_pre_conditions, + MockChangeGuard: pallet_mock_change_guard, + OrmlTokens: orml_tokens, + FakeNav: cfg_test_utils::mocks::nav::{Pallet, Storage}, + PoolFees: pallet_pool_fees + } +); + +impl frame_system::Config for Runtime { + type AccountData = pallet_balances::AccountData; + type AccountId = AccountId; + type BaseCallFilter = frame_support::traits::Everything; + type BlockHashCount = ConstU64<250>; + type BlockLength = (); + type BlockNumber = u64; + type BlockWeights = (); + type DbWeight = (); + type Hash = H256; + type Hashing = BlakeTwo256; + type Header = Header; + type Index = u64; + type Lookup = IdentityLookup; + type MaxConsumers = ConstU32<16>; + type OnKilledAccount = (); + type OnNewAccount = (); + type OnSetCode = (); + type PalletInfo = PalletInfo; + type RuntimeCall = RuntimeCall; + type RuntimeEvent = RuntimeEvent; + type RuntimeOrigin = RuntimeOrigin; + type SS58Prefix = ConstU16<42>; + type SystemWeightInfo = (); + type Version = (); +} + +impl pallet_balances::Config for Runtime { + type AccountStore = System; + type Balance = Balance; + type DustRemoval = (); + type ExistentialDeposit = ConstU128<1>; + type FreezeIdentifier = (); + type HoldIdentifier = (); + type MaxFreezes = (); + type MaxHolds = frame_support::traits::ConstU32<1>; + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = (); + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +impl pallet_mock_pools::Config for Runtime { + type Balance = Balance; + type BalanceRatio = Ratio; + type CurrencyId = CurrencyId; + type PoolId = PoolId; + type TrancheCurrency = TrancheCurrency; + type TrancheId = TrancheId; +} + +impl pallet_mock_permissions::Config for Runtime { + type Scope = PermissionScope; +} + +impl pallet_mock_change_guard::Config for Runtime { + type Change = Change; + type ChangeId = H256; + type PoolId = PoolId; +} + +orml_traits::parameter_type_with_key! { + pub ExistentialDeposits: |_currency_id: CurrencyId| -> Balance { + 1 + }; +} + +parameter_types! { + pub const MaxHolds: u32 = 10; + pub const MaxLocks: u32 = 10; + pub const MaxReserves: u32 = 10; +} + +impl orml_tokens::Config for Runtime { + type Amount = i64; + type Balance = Balance; + type CurrencyHooks = (); + type CurrencyId = CurrencyId; + type DustRemovalWhitelist = frame_support::traits::Nothing; + type ExistentialDeposits = ExistentialDeposits; + type MaxLocks = MaxLocks; + type MaxReserves = MaxReserves; + type ReserveIdentifier = [u8; 8]; + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +impl cfg_mocks::pallet_mock_time::Config for Runtime { + type Moment = u64; +} + +impl cfg_test_utils::mocks::nav::Config for Runtime { + type Balance = Balance; + type ClassId = CollectionId; + type PoolId = PoolId; +} + +impl pallet_mock_pre_conditions::Config for Runtime { + type Conditions = (AccountId, PoolId); + type Result = bool; +} + +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = cfg_primitives::MAX_POOL_FEES_PER_BUCKET; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; + pub const MaxFeesPerPool: u32 = cfg_primitives::MAX_FEES_PER_POOL; +} + +impl pallet_pool_fees::Config for Runtime { + type Balance = Balance; + type ChangeGuard = MockChangeGuard; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = MockIsAdmin; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = MockPools; + type Rate = Rate; + type RuntimeChange = Change; + type RuntimeEvent = RuntimeEvent; + type Time = MockTime; + type Tokens = OrmlTokens; +} + +pub fn new_fee(amount: PoolFeeType) -> PoolFeeInfoOf { + PoolFeeInfoOf:: { + destination: DESTINATION, + editor: PoolFeeEditor::Account(EDITOR), + fee_type: amount, + } +} + +pub fn fee_amounts() -> Vec> { + let amounts = vec![ + PoolFeeAmount::ShareOfPortfolioValuation(Rate::saturating_from_rational(1, 10)), + PoolFeeAmount::AmountPerSecond(1), + ]; + + amounts + .into_iter() + .map(|amount| { + vec![ + PoolFeeType::ChargedUpTo { + limit: amount.clone(), + }, + PoolFeeType::Fixed { limit: amount }, + ] + }) + .flatten() + .collect() +} + +pub fn get_disbursements() -> Vec { + ActiveFees::::get(POOL, BUCKET) + .into_iter() + .map(|fee| fee.amounts.disbursement.clone()) + .collect() +} + +pub fn default_fixed_fee() -> PoolFeeInfoOf { + new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::ShareOfPortfolioValuation(Rate::saturating_from_rational(1, 10)), + }) +} + +pub fn default_chargeable_fee() -> PoolFeeInfoOf { + new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(1), + }) +} + +pub fn default_fees() -> Vec> { + fee_amounts() + .into_iter() + .map(|amount| new_fee(amount)) + .collect() +} + +pub fn default_chargeable_fees() -> Vec> { + default_fees() + .into_iter() + .filter(|fee| match fee.fee_type { + PoolFeeType::ChargedUpTo { .. } => true, + _ => false, + }) + .collect() +} + +/// Add the given fees to the storage and ensure storage integrity +pub(crate) fn add_fees(pool_fees: Vec>) { + for fee in pool_fees.into_iter() { + config_change_mocks(&fee); + let last_fee_id = LastFeeId::::get(); + + assert_ok!(PoolFees::apply_new_fee( + RuntimeOrigin::signed(ANY), + POOL, + CHANGE_ID + )); + + // Verify storage invariants + let fee_id = LastFeeId::::get(); + assert_eq!(last_fee_id + 1, fee_id); + assert!(FeeIds::::get(POOL, BUCKET) + .into_iter() + .find(|id| id == &fee_id) + .is_some()); + assert_ok!(PoolFees::get_active_fee(fee_id)); + assert_eq!( + FeeIdsToPoolBucket::::get(fee_id), + Some((POOL, BUCKET)) + ); + + System::assert_last_event( + Event::::Added { + pool_id: POOL, + bucket: BUCKET, + fee_id, + fee, + } + .into(), + ); + } +} + +pub fn pay_single_fee_and_assert( + fee_id: ::FeeId, + fee_amount: Balance, +) { + assert_ok!(PoolFees::pay_active_fees(POOL, BUCKET)); + assert!(PoolFees::get_active_fee(fee_id) + .expect("Fee exists") + .amounts + .disbursement + .is_zero()); + assert_eq!(OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), fee_amount); + + if !fee_amount.is_zero() { + System::assert_last_event( + Event::Paid { + fee_id, + amount: fee_amount, + destination: DESTINATION, + } + .into(), + ); + } +} + +pub fn assert_pending_fee( + fee_id: PoolFeeId, + fee: PoolFeeInfoOf, + pending: Balance, + payable: Balance, + disbursement: Balance, +) { + let mut pending_fee = PoolFeeOf::::from_info(fee.clone(), fee_id); + pending_fee.amounts.disbursement = disbursement; + pending_fee.amounts.pending = pending; + + match fee.fee_type { + PoolFeeType::ChargedUpTo { .. } => { + pending_fee.amounts.payable = PayableFeeAmount::UpTo(payable); + } + _ => (), + }; + + assert_eq!(PoolFees::get_active_fee(fee_id), Ok(pending_fee)); +} + +pub(crate) fn init_mocks() { + #[cfg(not(feature = "runtime-benchmarks"))] + MockIsAdmin::mock_check(|(pool_id, admin)| pool_id == POOL && admin == ADMIN); + #[cfg(feature = "runtime-benchmarks")] + MockIsAdmin::mock_check(|_| true); + + #[cfg(not(feature = "runtime-benchmarks"))] + MockPools::mock_pool_exists(|id| id == POOL); + #[cfg(feature = "runtime-benchmarks")] + MockPools::mock_pool_exists(|_| true); + + #[cfg(not(feature = "runtime-benchmarks"))] + MockChangeGuard::mock_released(move |_, _| Err(ERR_CHANGE_GUARD_RELEASE)); + #[cfg(feature = "runtime-benchmarks")] + MockChangeGuard::mock_released(|_, _| { + Ok(Change::AppendFee( + PoolFeeBucket::Top, + ::get_default_fixed_fee_info(), + )) + }); + + MockPools::mock_account_for(|_| POOL_ACCOUNT); + MockPools::mock_currency_for(|_| Some(POOL_CURRENCY)); + MockPools::mock_withdraw(|_, recipient, amount| { + OrmlTokens::mint_into(POOL_CURRENCY, &recipient, amount) + .map(|_| ()) + .map_err(|e: DispatchError| e) + }); + MockPools::mock_deposit(|_, _, _| Ok(())); + MockChangeGuard::mock_note(|_, _| Ok(H256::default())); + MockTime::mock_now(|| 0); +} + +pub(crate) fn config_change_mocks(fee: &PoolFeeInfoOf) { + let pool_fee = fee.clone(); + MockChangeGuard::mock_note({ + move |pool_id, change| { + assert_eq!(pool_id, POOL); + assert_eq!(change, Change::AppendFee(BUCKET, pool_fee.clone())); + Ok(CHANGE_ID) + } + }); + + MockChangeGuard::mock_released({ + let pool_fee = fee.clone(); + move |pool_id, change_id| { + assert_eq!(pool_id, POOL); + assert_eq!(change_id, CHANGE_ID); + Ok(Change::AppendFee(BUCKET, pool_fee.clone())) + } + }); +} + +#[derive(Default)] +pub(crate) struct ExtBuilder { + aum: Balance, +} + +impl ExtBuilder { + pub(crate) fn set_aum(mut self, aum: Balance) -> Self { + self.aum = aum; + self + } + + pub(crate) fn build(self) -> sp_io::TestExternalities { + let storage = frame_system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(storage); + + // Bumping to one enables events + ext.execute_with(|| { + init_mocks(); + System::set_block_number(1); + + // Fund pallet account + OrmlTokens::mint_into(POOL_CURRENCY, &POOL_ACCOUNT, u128::MAX).unwrap(); + + AssetsUnderManagement::::insert(POOL, self.aum); + + // Update NAV to set timestamp to now + PoolFees::update_nav(POOL).unwrap(); + }); + ext + } +} diff --git a/pallets/pool-fees/src/tests.rs b/pallets/pool-fees/src/tests.rs new file mode 100644 index 0000000000..8a798b7064 --- /dev/null +++ b/pallets/pool-fees/src/tests.rs @@ -0,0 +1,1278 @@ +use cfg_primitives::Balance; +use frame_support::{assert_noop, assert_ok}; +use rand::Rng; +use sp_arithmetic::FixedPointNumber; + +use super::*; +use crate::mock::{ + add_fees, assert_pending_fee, config_change_mocks, default_chargeable_fees, default_fees, + default_fixed_fee, new_fee, ExtBuilder, OrmlTokens, PoolFees, Runtime, RuntimeOrigin, System, + ADMIN, ANY, BUCKET, CHANGE_ID, DESTINATION, EDITOR, ERR_CHANGE_GUARD_RELEASE, NOT_ADMIN, + NOT_DESTINATION, NOT_EDITOR, POOL, +}; + +mod extrinsics { + use super::*; + + mod should_work { + use super::*; + + #[test] + fn propose_new_fee_works() { + ExtBuilder::default().build().execute_with(|| { + let fees = default_fees(); + + for (i, fee) in fees.into_iter().enumerate() { + assert!( + PoolFees::propose_new_fee( + RuntimeOrigin::signed(ADMIN), + POOL, + BUCKET, + fee.clone() + ) + .is_ok(), + "Failed to propose fee {:?} at position {:?}", + fee, + i + ); + + System::assert_last_event( + Event::::Proposed { + pool_id: POOL, + bucket: BUCKET, + fee, + } + .into(), + ); + } + }) + } + + #[test] + fn apply_new_fee_works() { + ExtBuilder::default().build().execute_with(|| { + config_change_mocks(&default_fixed_fee()); + + assert_ok!(PoolFees::apply_new_fee( + RuntimeOrigin::signed(ANY), + POOL, + CHANGE_ID + )); + + System::assert_last_event( + Event::::Added { + pool_id: POOL, + bucket: BUCKET, + fee: default_fixed_fee(), + fee_id: 1, + } + .into(), + ); + }) + } + + #[test] + fn remove_only_fee_works() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + assert_ok!(PoolFees::remove_fee(RuntimeOrigin::signed(EDITOR), 1)); + + System::assert_last_event( + Event::::Removed { + pool_id: POOL, + bucket: BUCKET, + fee_id: 1, + } + .into(), + ); + }) + } + + #[test] + fn remove_fee_works() { + ExtBuilder::default().build().execute_with(|| { + let pool_fees = default_fees(); + assert!(pool_fees.len() > 1); + add_fees(pool_fees); + + let last_fee_id = LastFeeId::::get(); + assert_ok!(PoolFees::remove_fee( + RuntimeOrigin::signed(EDITOR), + last_fee_id + )); + System::assert_last_event( + Event::::Removed { + pool_id: POOL, + bucket: BUCKET, + fee_id: last_fee_id, + } + .into(), + ); + }) + } + + #[test] + fn charge_fee_works() { + ExtBuilder::default().build().execute_with(|| { + let pool_fees = default_chargeable_fees(); + add_fees(pool_fees.clone()); + + for (i, fee) in pool_fees.into_iter().enumerate() { + let fee_id = (i + 1) as u64; + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + 1000 + )); + assert_pending_fee(fee_id, fee.clone(), 1000, 0, 0); + System::assert_last_event( + Event::::Charged { + fee_id, + amount: 1000, + pending: 1000, + } + .into(), + ); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + 337 + )); + assert_pending_fee(fee_id, fee.clone(), 1337, 0, 0); + System::assert_last_event( + Event::::Charged { + fee_id, + amount: 337, + pending: 1337, + } + .into(), + ); + } + }) + } + + #[test] + fn uncharge_fee_works() { + ExtBuilder::default().build().execute_with(|| { + let pool_fees = default_chargeable_fees(); + add_fees(pool_fees.clone()); + let mut rng = rand::thread_rng(); + + for (i, fee) in pool_fees.into_iter().enumerate() { + let fee_id = (i + 1) as u64; + let charge_amount: Balance = rng.gen_range(1..u128::MAX); + let uncharge_amount: Balance = rng.gen_range(1..=charge_amount); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charge_amount + )); + + assert_ok!(PoolFees::uncharge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + uncharge_amount + )); + assert_pending_fee(fee_id, fee.clone(), charge_amount - uncharge_amount, 0, 0); + + System::assert_last_event( + Event::::Uncharged { + fee_id, + amount: uncharge_amount, + pending: charge_amount - uncharge_amount, + } + .into(), + ); + } + }) + } + } + + mod should_fail { + use sp_arithmetic::ArithmeticError; + use sp_runtime::DispatchError; + + use super::*; + use crate::{ + mock::{ + default_chargeable_fee, ExtBuilder, MaxPoolFeesPerBucket, MockChangeGuard, + MockIsAdmin, MockPools, + }, + types::Change, + }; + + #[test] + fn propose_new_fee_wrong_origin() { + ExtBuilder::default().build().execute_with(|| { + MockIsAdmin::mock_check(|_| false); + let fees = default_fees(); + + for account in NOT_ADMIN { + assert_noop!( + PoolFees::propose_new_fee( + RuntimeOrigin::signed(account), + POOL, + BUCKET, + fees.get(0).unwrap().clone() + ), + Error::::NotPoolAdmin + ); + } + }) + } + + #[test] + fn propose_new_fee_missing_pool() { + ExtBuilder::default().build().execute_with(|| { + MockPools::mock_pool_exists(|_| false); + assert_noop!( + PoolFees::propose_new_fee( + RuntimeOrigin::signed(ADMIN), + POOL, + BUCKET, + default_fixed_fee() + ), + Error::::PoolNotFound + ); + }) + } + + #[test] + fn apply_new_fee_changeguard_unreleased() { + ExtBuilder::default().build().execute_with(|| { + MockChangeGuard::mock_released(move |_, _| Err(ERR_CHANGE_GUARD_RELEASE)); + + // Requires mocking ChangeGuard::release + assert_noop!( + PoolFees::apply_new_fee(RuntimeOrigin::signed(ANY), POOL, CHANGE_ID), + ERR_CHANGE_GUARD_RELEASE + ); + }) + } + + #[test] + fn apply_new_fee_missing_pool() { + ExtBuilder::default().build().execute_with(|| { + MockPools::mock_pool_exists(|_| false); + // Requires mocking ChangeGuard::release + assert_noop!( + PoolFees::apply_new_fee(RuntimeOrigin::signed(ANY), POOL, CHANGE_ID), + Error::::PoolNotFound + ); + }) + } + + #[test] + fn remove_fee_wrong_origin() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + for account in NOT_EDITOR { + assert_noop!( + PoolFees::remove_fee(RuntimeOrigin::signed(account), 1), + Error::::UnauthorizedEdit + ); + } + }) + } + + #[test] + fn remove_fee_missing_fee() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + PoolFees::remove_fee(RuntimeOrigin::signed(EDITOR), 1), + Error::::FeeNotFound + ); + }) + } + + #[test] + fn charge_fee_wrong_origin() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + for account in NOT_DESTINATION { + assert_noop!( + PoolFees::charge_fee(RuntimeOrigin::signed(account), 1, 1000), + Error::::UnauthorizedCharge + ); + } + }) + } + + #[test] + fn charge_fee_missing_fee() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + PoolFees::charge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1000), + Error::::FeeNotFound + ); + }) + } + + #[test] + fn charge_fee_overflow() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_chargeable_fee()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + 1, + u128::MAX + )); + assert_noop!( + PoolFees::charge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1), + DispatchError::Arithmetic(ArithmeticError::Overflow) + ); + }) + } + + #[test] + fn uncharge_fee_wrong_origin() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_chargeable_fee()]); + + for account in NOT_DESTINATION { + assert_noop!( + PoolFees::uncharge_fee(RuntimeOrigin::signed(account), 1, 1000), + Error::::UnauthorizedCharge + ); + } + }) + } + + #[test] + fn uncharge_fee_missing_fee() { + ExtBuilder::default().build().execute_with(|| { + assert_noop!( + PoolFees::uncharge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1000), + Error::::FeeNotFound + ); + }) + } + + #[test] + fn uncharge_fee_overflow() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_chargeable_fee()]); + + assert_noop!( + PoolFees::uncharge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1), + DispatchError::Arithmetic(ArithmeticError::Underflow) + ); + }) + } + + #[test] + fn cannot_charge_fixed() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + assert_noop!( + PoolFees::charge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1), + Error::::CannotBeCharged + ); + }); + } + + #[test] + fn cannot_uncharge_fixed() { + ExtBuilder::default().build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + assert_noop!( + PoolFees::uncharge_fee(RuntimeOrigin::signed(DESTINATION), 1, 1), + Error::::CannotBeCharged + ); + }); + } + + #[test] + fn max_fees_per_bucket() { + ExtBuilder::default().build().execute_with(|| { + while (ActiveFees::::get(POOL, BUCKET).len() as u32) + < MaxPoolFeesPerBucket::get() + { + add_fees(vec![default_fixed_fee()]); + } + MockChangeGuard::mock_released(|_, _| { + Ok(Change::AppendFee(BUCKET, default_fixed_fee())) + }); + + assert_noop!( + PoolFees::apply_new_fee(RuntimeOrigin::signed(ANY), POOL, CHANGE_ID), + Error::::MaxPoolFeesPerBucket + ); + }); + } + } +} + +mod disbursements { + use cfg_primitives::SECONDS_PER_YEAR; + use cfg_traits::{EpochTransitionHook, PoolNAV, TimeAsSecs}; + use cfg_types::{ + fixed_point::Rate, + pools::{PoolFeeAmount, PoolFeeType}, + }; + use frame_support::traits::fungibles::Inspect; + + use super::*; + use crate::mock::{ + get_disbursements, pay_single_fee_and_assert, MockTime, NAV, POOL_CURRENCY, SECONDS, + }; + + mod single_fee { + use super::*; + + mod fixed { + use super::*; + + mod share_of_portfolio_valuation { + use super::*; + + #[test] + fn sufficient_reserve_sfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + let fee_amount = res_pre_fees / 10; + + let fee = new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + // Fees (10% of NAV) consume 10% of reserve + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - fee_amount); + assert_eq!(get_disbursements(), vec![fee_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, fee_amount); + }); + } + + #[test] + fn insufficient_reserve_sfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV / 100; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + + let fee = new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + // Fees (10% of NAV) consume entire reserve + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, 0); + assert_eq!(get_disbursements(), vec![res_pre_fees]); + assert_eq!( + PoolFees::nav(POOL), + Some((NAV / 10 - res_pre_fees, MockTime::now())) + ); + + pay_single_fee_and_assert(fee_id, res_pre_fees); + }); + } + } + + mod amount_per_second { + use super::*; + #[test] + fn sufficient_reserve_sfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees: Balance = (2 * SECONDS_PER_YEAR).into(); + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + let fee_amount = SECONDS_PER_YEAR.into(); + + let fee = new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + // Fees (10% of NAV) consume 10% of reserve + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - fee_amount); + assert_eq!(get_disbursements(), vec![fee_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, fee_amount); + }); + } + + #[test] + fn insufficient_reserve_sfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees: Balance = (SECONDS_PER_YEAR / 2).into(); + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + + let fee = new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + // Fees (10% of NAV) consume entire reserve + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, 0); + assert_eq!(get_disbursements(), vec![res_pre_fees]); + assert_eq!(PoolFees::nav(POOL), Some((res_pre_fees, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, res_pre_fees); + }); + } + } + + #[test] + fn no_disbursement_without_prep_sfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + assert_eq!(OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), 0); + assert_ok!(PoolFees::on_execution_pre_fulfillments(POOL)); + assert_eq!(OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), 0); + }); + } + } + + mod charged_up_to { + use super::*; + use crate::mock::{default_chargeable_fee, MockPools}; + + mod fixed { + + use super::*; + + mod share_of_portfolio { + use super::*; + use crate::mock::assert_pending_fee; + #[test] + fn empty_charge_scfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees); + assert_eq!(get_disbursements().into_iter().sum::(), 0); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, 0); + }); + } + + #[test] + fn below_max_charge_sufficient_reserve_scfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + let charged_amount = NAV / 10 - 1; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - charged_amount); + assert_eq!(get_disbursements(), vec![charged_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, charged_amount); + }); + } + + #[test] + fn max_charge_sufficient_reserve_scfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + let charged_amount = NAV / 10; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - charged_amount); + assert_eq!(get_disbursements(), vec![charged_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, charged_amount); + }); + } + + #[test] + fn excess_charge_sufficient_reserve_scfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + let max_chargeable_amount = NAV / 10; + let charged_amount = max_chargeable_amount + 1; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - max_chargeable_amount); + assert_eq!(get_disbursements(), vec![max_chargeable_amount]); + assert_pending_fee(fee_id, fee.clone(), 1, 0, max_chargeable_amount); + assert_eq!(PoolFees::nav(POOL), Some((1, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, max_chargeable_amount); + }); + } + + #[test] + fn insufficient_reserve_scfs() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV / 100; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 10); + let charged_amount = NAV / 10; + let fee_amount = res_pre_fees; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, 0); + assert_eq!(get_disbursements(), vec![fee_amount]); + assert_pending_fee( + fee_id, + fee.clone(), + charged_amount - fee_amount, + charged_amount - fee_amount, + fee_amount, + ); + assert_eq!( + PoolFees::nav(POOL), + Some((charged_amount - fee_amount, MockTime::now())) + ); + + pay_single_fee_and_assert(fee_id, fee_amount); + }); + } + } + + mod amount_per_second { + use cfg_traits::EpochTransitionHook; + + use super::*; + use crate::mock::assert_pending_fee; + + #[test] + fn empty_charge_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees); + assert_eq!(get_disbursements().into_iter().sum::(), 0); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, 0); + }); + } + + #[test] + fn below_max_charge_sufficient_reserve_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + let charged_amount = (SECONDS_PER_YEAR - 1).into(); + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - charged_amount); + assert_eq!(get_disbursements(), vec![charged_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, charged_amount); + }); + } + + #[test] + fn max_charge_sufficient_reserve_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + let charged_amount = SECONDS_PER_YEAR.into(); + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - charged_amount); + assert_eq!(get_disbursements(), vec![charged_amount]); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, charged_amount); + }); + } + + #[test] + fn excess_charge_sufficient_reserve_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let amount_per_second = 1; + let max_chargeable_amount = SECONDS_PER_YEAR.into(); + let charged_amount = max_chargeable_amount + 1; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, res_pre_fees - max_chargeable_amount); + assert_eq!(get_disbursements(), vec![max_chargeable_amount]); + assert_pending_fee(fee_id, fee.clone(), 1, 0, max_chargeable_amount); + assert_eq!(PoolFees::nav(POOL), Some((1, MockTime::now()))); + + pay_single_fee_and_assert(fee_id, max_chargeable_amount); + }); + } + + #[test] + fn insufficient_reserve_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let fee_id = 1; + let amount_per_second = 1; + let res_pre_fees: Balance = (SECONDS_PER_YEAR / 2 + 1).into(); + let res_post_fees = &mut res_pre_fees.clone(); + let charged_amount = SECONDS_PER_YEAR.into(); + let fee_amount = res_pre_fees; + + let fee = new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_second), + }); + add_fees(vec![fee.clone()]); + + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + fee_id, + charged_amount + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV + 100, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV + 100); + + assert_eq!(*res_post_fees, 0); + assert_eq!(get_disbursements(), vec![fee_amount]); + assert_pending_fee( + fee_id, + fee.clone(), + charged_amount - fee_amount, + charged_amount - fee_amount, + fee_amount, + ); + assert_eq!( + PoolFees::nav(POOL), + Some((charged_amount - fee_amount, MockTime::now())) + ); + + pay_single_fee_and_assert(fee_id, fee_amount); + }); + } + } + } + + #[test] + fn no_disbursement_without_prep_scfa() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + add_fees(vec![default_chargeable_fee()]); + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + 1, + 1000 + )); + + assert_eq!(OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), 0); + assert_ok!(PoolFees::on_execution_pre_fulfillments(POOL)); + assert_eq!(OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), 0); + }); + } + + #[test] + fn update_nav_pool_missing() { + ExtBuilder::default().build().execute_with(|| { + MockPools::mock_pool_exists(|_| false); + assert_noop!( + PoolFees::update_portfolio_valuation(RuntimeOrigin::signed(ANY), POOL), + Error::::PoolNotFound + ); + }); + } + } + } + + mod nav { + use cfg_types::portfolio::PortfolioValuationUpdateType; + + use super::*; + use crate::mock::default_chargeable_fee; + + #[test] + fn update_empty() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + assert_ok!(PoolFees::update_portfolio_valuation( + RuntimeOrigin::signed(ANY), + POOL + )); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + System::assert_last_event( + Event::PortfolioValuationUpdated { + pool_id: POOL, + valuation: 0, + update_type: PortfolioValuationUpdateType::Exact, + } + .into(), + ); + }); + } + + #[test] + fn update_single_fixed() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + add_fees(vec![default_fixed_fee()]); + + assert_eq!(PoolFees::nav(POOL), Some((0, 0))); + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + assert_eq!(PoolFees::nav(POOL), Some((0, 0))); + assert_ok!(PoolFees::update_portfolio_valuation( + RuntimeOrigin::signed(ANY), + POOL + )); + assert_eq!(PoolFees::nav(POOL), Some((NAV / 10, MockTime::now()))); + System::assert_last_event( + Event::PortfolioValuationUpdated { + pool_id: POOL, + valuation: NAV / 10, + update_type: PortfolioValuationUpdateType::Exact, + } + .into(), + ); + }); + } + + #[test] + fn update_single_charged() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + add_fees(vec![default_chargeable_fee()]); + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + assert_eq!(PoolFees::nav(POOL), Some((0, 0))); + assert_ok!(PoolFees::update_portfolio_valuation( + RuntimeOrigin::signed(ANY), + POOL + )); + assert_eq!(PoolFees::nav(POOL), Some((0, MockTime::now()))); + System::assert_last_event( + Event::PortfolioValuationUpdated { + pool_id: POOL, + valuation: 0, + update_type: PortfolioValuationUpdateType::Exact, + } + .into(), + ); + }); + } + } + + mod waterfall { + use super::*; + use crate::mock::assert_pending_fee; + + #[test] + fn fixed_charged_charged() { + ExtBuilder::default().set_aum(NAV).build().execute_with(|| { + MockTime::mock_now(|| SECONDS_PER_YEAR * SECONDS); + + let charged_fee_ids = vec![2, 3]; + let res_pre_fees = NAV; + let res_post_fees = &mut res_pre_fees.clone(); + let annual_rate = Rate::saturating_from_rational(1, 100); + let fixed_fee_amount = NAV / 100; + let amount_per_seconds = vec![2, 1]; + let payable = vec![(2 * SECONDS_PER_YEAR).into(), SECONDS_PER_YEAR.into()]; + let charged_y1 = vec![1, 2 * payable[1]]; + let charged_y2 = vec![payable[0], payable[1]]; + + let fees = vec![ + new_fee(PoolFeeType::Fixed { + limit: PoolFeeAmount::ShareOfPortfolioValuation(annual_rate), + }), + new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_seconds[0]), + }), + new_fee(PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(amount_per_seconds[1]), + }), + ]; + add_fees(fees.clone()); + + // Year 1 + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + charged_fee_ids[0], + charged_y1[0] + )); + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + charged_fee_ids[1], + charged_y1[1] + )); + + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV, + res_post_fees + )); + assert_eq!(AssetsUnderManagement::::get(POOL), NAV); + assert_eq!( + *res_post_fees, + res_pre_fees - fixed_fee_amount - charged_y1[0] - payable[1] + ); + assert_eq!( + get_disbursements(), + vec![fixed_fee_amount, charged_y1[0], payable[1]] + ); + assert_pending_fee( + charged_fee_ids[0], + fees[1].clone(), + 0, + payable[0] - charged_y1[0], + charged_y1[0], + ); + assert_pending_fee( + charged_fee_ids[1], + fees[2].clone(), + payable[1], + 0, + charged_y1[1] - payable[1], + ); + assert_eq!(PoolFees::nav(POOL), Some((payable[1], MockTime::now()))); + + // Pay disbursements + assert_ok!(PoolFees::on_execution_pre_fulfillments(POOL)); + assert_eq!(get_disbursements().into_iter().sum::(), 0); + assert_eq!(PoolFees::nav(POOL), Some((payable[1], MockTime::now()))); + assert_eq!( + OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), + fixed_fee_amount + charged_y1[0] + payable[1] + ); + System::assert_has_event( + Event::Paid { + fee_id: 1, + amount: fixed_fee_amount, + destination: DESTINATION, + } + .into(), + ); + System::assert_has_event( + Event::Paid { + fee_id: charged_fee_ids[0], + amount: charged_y1[0], + destination: DESTINATION, + } + .into(), + ); + System::assert_last_event( + Event::Paid { + fee_id: charged_fee_ids[1], + amount: payable[1], + destination: DESTINATION, + } + .into(), + ); + + // Year 2: Make reserve insufficient to handle all fees (last fee + // falls short + MockTime::mock_now(|| 2 * SECONDS_PER_YEAR * SECONDS); + let res_pre_fees = fixed_fee_amount + charged_y2[0] + 1; + let res_post_fees = &mut res_pre_fees.clone(); + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + charged_fee_ids[0], + charged_y2[0] + )); + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DESTINATION), + charged_fee_ids[1], + charged_y2[1] + )); + assert_ok!(PoolFees::on_closing_mutate_reserve( + POOL, + NAV, + res_post_fees + )); + assert_eq!(*res_post_fees, 0); + assert_eq!( + get_disbursements(), + vec![fixed_fee_amount, charged_y2[0], 1] + ); + assert_pending_fee( + charged_fee_ids[0], + fees[1].clone(), + 0, + 2 * payable[0] - charged_y1[0] - charged_y2[0], + charged_y2[0], + ); + assert_pending_fee( + charged_fee_ids[1], + fees[2].clone(), + 2 * payable[1] - 1, + payable[1] - 1, + 1, + ); + assert_eq!( + PoolFees::nav(POOL), + Some((2 * payable[1] - 1, MockTime::now())) + ); + + // Pay disbursements + assert_ok!(PoolFees::on_execution_pre_fulfillments(POOL)); + assert_eq!(get_disbursements().into_iter().sum::(), 0); + assert_eq!( + PoolFees::nav(POOL), + Some((2 * payable[1] - 1, MockTime::now())) + ); + assert_eq!( + OrmlTokens::balance(POOL_CURRENCY, &DESTINATION), + 2 * fixed_fee_amount + charged_y1[0] + payable[1] + charged_y2[0] + 1 + ); + + System::assert_has_event( + Event::Paid { + fee_id: 1, + amount: fixed_fee_amount, + destination: DESTINATION, + } + .into(), + ); + System::assert_has_event( + Event::Paid { + fee_id: charged_fee_ids[0], + amount: charged_y2[0], + destination: DESTINATION, + } + .into(), + ); + System::assert_last_event( + Event::Paid { + fee_id: charged_fee_ids[1], + amount: 1, + destination: DESTINATION, + } + .into(), + ); + }); + } + } +} diff --git a/pallets/pool-fees/src/types.rs b/pallets/pool-fees/src/types.rs new file mode 100644 index 0000000000..3db29dee1e --- /dev/null +++ b/pallets/pool-fees/src/types.rs @@ -0,0 +1,25 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). + +// Centrifuge 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 (see http://www.gnu.org/licenses). + +// Centrifuge 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. + +use cfg_traits::fee::PoolFeeBucket; +use frame_support::dispatch::TypeInfo; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; + +use crate::{Config, PoolFeeInfoOf}; + +/// Represents pool changes which might require to complete further guarding +/// checks. +#[derive(Debug, Encode, Decode, TypeInfo, MaxEncodedLen, PartialEq, Eq, Clone)] +#[scale_info(skip_type_params(T))] +pub enum Change { + AppendFee(PoolFeeBucket, PoolFeeInfoOf), +} diff --git a/pallets/pool-registry/Cargo.toml b/pallets/pool-registry/Cargo.toml index dcecec0dfa..3e22130f6b 100644 --- a/pallets/pool-registry/Cargo.toml +++ b/pallets/pool-registry/Cargo.toml @@ -26,6 +26,7 @@ xcm = { git = "https://github.com/paritytech/polkadot", default-features = false # Benchmarking dependencies - optional frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.43" } pallet-investments = { path = "../investments", default-features = false, optional = true } +pallet-pool-fees = { workspace = true, default-features = false, optional = true } pallet-pool-system = { path = "../pool-system", default-features = false, optional = true } pallet-timestamp = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.43" } @@ -59,6 +60,7 @@ runtime-benchmarks = [ "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", "orml-asset-registry/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "sp-runtime/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", @@ -78,6 +80,7 @@ std = [ "pallet-investments/std", "scale-info/std", "xcm/std", + "pallet-pool-fees/std", "pallet-pool-system/std", ] try-runtime = [ @@ -88,6 +91,7 @@ try-runtime = [ "cfg-types/try-runtime", "frame-system/try-runtime", "orml-asset-registry/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-investments/try-runtime", "sp-runtime/try-runtime", ] diff --git a/pallets/pool-registry/src/benchmarking.rs b/pallets/pool-registry/src/benchmarking.rs index ce2f796a24..1f97ea34ee 100644 --- a/pallets/pool-registry/src/benchmarking.rs +++ b/pallets/pool-registry/src/benchmarking.rs @@ -13,7 +13,9 @@ //! Module provides benchmarking for Loan Pallet use cfg_primitives::PoolEpochId; -use cfg_traits::investments::TrancheCurrency as _; +use cfg_traits::{ + benchmarking::PoolFeesBenchmarkHelper, fee::PoolFeeBucket, investments::TrancheCurrency as _, +}; use cfg_types::{ pools::TrancheMetadata, tokens::{CurrencyId, TrancheCurrency}, @@ -55,7 +57,8 @@ const AUSD_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(1); benchmarks! { where_clause { where - T: Config + pallet_investments::Config, @@ -68,6 +71,11 @@ benchmarks! { MaxTokenNameLength = ::MaxTokenNameLength, MaxTokenSymbolLength = ::MaxTokenSymbolLength, MaxTranches = ::MaxTranches>, + T: pallet_pool_fees::Config, + ::PoolFees: PoolFeesBenchmarkHelper< + PoolId = ::PoolId, + PoolFeeInfo = PoolFeeInfo::Balance, ::Rate>, + >, ::Tokens: Inspect, <::Lookup as sp_runtime::traits::StaticLookup>::Source: From<::AccountId>, @@ -84,13 +92,17 @@ benchmarks! { ::MaxTokenNameLength, ::MaxTokenSymbolLength, ::MaxTranches>, + PoolFeeInput = (PoolFeeBucket, <::PoolFees as PoolFeesBenchmarkHelper>::PoolFeeInfo), >, + Vec<(PoolFeeBucket, PoolFeeInfo<::AccountId, u128, ::InterestRate>)>: FromIterator<(PoolFeeBucket, PoolFeeInfo<::AccountId, u128, ::Rate>)> } register { let n in 1..::MaxTranches::get(); + let m in 0..::MaxPoolFeesPerBucket::get(); let caller: ::AccountId = create_admin::(0); let tranches = build_bench_input_tranches::(n); - let origin = if let Ok(_) = ::PoolCreateOrigin::try_origin(RawOrigin::Signed(caller.clone()).into()) { + let pool_fee_input = as PoolFeesBenchmarkHelper>::get_pool_fee_infos(m).into_iter().map(|fee| (PoolFeeBucket::Top, fee)).collect(); + let origin = if ::PoolCreateOrigin::try_origin(RawOrigin::Signed(caller.clone()).into()).is_ok() { RawOrigin::Signed(caller.clone()) } else { RawOrigin::Root @@ -101,7 +113,7 @@ benchmarks! { mock::MockWriteOffPolicy::mock_worst_case_policy(|| ()); let policy = T::ModifyWriteOffPolicy::worst_case_policy(); - }: register(origin, caller, POOL, tranches.clone(), AUSD_CURRENCY_ID, MAX_RESERVE, None, policy) + }: register(origin, caller, POOL, tranches.clone(), AUSD_CURRENCY_ID, MAX_RESERVE, None, policy, pool_fee_input) verify { let pool = get_pool::(); assert_input_tranches_match::(pool.tranches.residual_top_slice(), &tranches); @@ -116,9 +128,10 @@ benchmarks! { // since we submitted the update let admin: ::AccountId = create_admin::(0); let n in 1..::MaxTranches::get(); + let m in 0..::MaxPoolFeesPerBucket::get(); let tranches = build_update_tranches::(n); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; + create_pool::(n, m, admin.clone())?; // Submit redemption order so the update isn't executed @@ -148,9 +161,10 @@ benchmarks! { update_and_execute { let admin: T::AccountId = create_admin::(0); let n in 1..::MaxTranches::get(); + let m in 0..::MaxPoolFeesPerBucket::get(); let tranches = build_update_tranches::(n); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; + create_pool::(n, m, admin.clone())?; let changes = PoolChanges { tranches: Change::NewValue(tranches.clone()), @@ -170,9 +184,10 @@ benchmarks! { execute_update { let admin: T::AccountId = create_admin::(0); let n in 1..::MaxTranches::get(); + let m in 0..::MaxPoolFeesPerBucket::get(); let tranches = build_update_tranches::(n); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; + create_pool::(n, m, admin.clone())?; let pool = get_pool::(); let default_min_epoch_time = pool.parameters.min_epoch_time; @@ -205,9 +220,10 @@ benchmarks! { set_metadata { let n in 0..::MaxSizeMetadata::get(); + let m in 0..::MaxPoolFeesPerBucket::get(); let caller: ::AccountId = create_admin::(0); prepare_asset_registry::(); - create_pool::(2, caller.clone())?; + create_pool::(2, m, caller.clone())?; let metadata = vec![0u8; n as usize]; }: set_metadata(RawOrigin::Signed(caller), POOL, metadata.clone()) verify { @@ -217,7 +233,7 @@ benchmarks! { } fn get_pool_metadata>() -> PoolMetadataOf { - Pallet::::get_pool_metadata(T::PoolId::from(POOL)).unwrap() + Pallet::::get_pool_metadata(POOL).unwrap() } fn build_update_tranche_token_metadata( diff --git a/pallets/pool-registry/src/lib.rs b/pallets/pool-registry/src/lib.rs index ba675b5c9a..751eb34565 100644 --- a/pallets/pool-registry/src/lib.rs +++ b/pallets/pool-registry/src/lib.rs @@ -11,13 +11,15 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. #![cfg_attr(not(feature = "std"), no_std)] +#![allow(clippy::too_many_arguments)] use cfg_traits::{ - investments::TrancheCurrency, Permissions, PoolMutate, PoolWriteOffPolicyMutate, UpdateState, + fee::PoolFeeBucket, investments::TrancheCurrency, Permissions, PoolMutate, + PoolWriteOffPolicyMutate, UpdateState, }; use cfg_types::{ permissions::{PermissionScope, PoolRole, Role}, - pools::{PoolMetadata, PoolRegistrationStatus}, + pools::{PoolFeeInfo, PoolMetadata, PoolRegistrationStatus}, tokens::CustomMetadata, }; use frame_support::{pallet_prelude::*, scale_info::TypeInfo, transactional, BoundedVec}; @@ -57,9 +59,13 @@ type PolicyOf = <::ModifyWriteOffPolicy as cfg_traits::PoolWrite ::PoolId, >>::Policy; +type PoolFeeInputOf = <::ModifyPool as cfg_traits::PoolMutate< + ::AccountId, + ::PoolId, +>>::PoolFeeInput; + #[frame_support::pallet] pub mod pallet { - use super::*; #[pallet::config] @@ -100,6 +106,10 @@ pub mod pallet { Self::PoolId, CurrencyId = Self::CurrencyId, Balance = Self::Balance, + PoolFeeInput = ( + PoolFeeBucket, + PoolFeeInfo, + ), >; type ModifyWriteOffPolicy: PoolWriteOffPolicyMutate; @@ -241,6 +251,7 @@ pub mod pallet { max_reserve: T::Balance, metadata: Option>, write_off_policy: PolicyOf, + pool_fees: Vec>, ) -> DispatchResult { T::PoolCreateOrigin::ensure_origin(origin.clone())?; @@ -278,6 +289,7 @@ pub mod pallet { tranche_inputs, currency, max_reserve, + pool_fees, ) .map(|_| Self::deposit_event(Event::Registered { pool_id }))?; diff --git a/pallets/pool-registry/src/mock.rs b/pallets/pool-registry/src/mock.rs index ee3a48ac07..232f740e24 100644 --- a/pallets/pool-registry/src/mock.rs +++ b/pallets/pool-registry/src/mock.rs @@ -11,15 +11,21 @@ // GNU General Public License for more details. use std::marker::PhantomData; -use cfg_mocks::pallet_mock_write_off_policy; -use cfg_primitives::{BlockNumber, CollectionId, PoolEpochId, TrancheWeight}; +use cfg_mocks::{ + pallet_mock_change_guard, pallet_mock_pre_conditions, pallet_mock_write_off_policy, +}; +use cfg_primitives::{ + Balance as BalanceType, BlockNumber, CollectionId, PoolEpochId, PoolFeeId, PoolId, TrancheId, + TrancheWeight, +}; use cfg_traits::{ - investments::OrderManager, Millis, PoolMutate, PoolUpdateGuard, PreConditions, Seconds, - UpdateState, + benchmarking::PoolFeesBenchmarkHelper, fee::PoolFeeBucket, investments::OrderManager, Millis, + PoolMutate, PoolUpdateGuard, PreConditions, Seconds, UpdateState, }; use cfg_types::{ fixed_point::{Quantity, Rate}, permissions::{PermissionScope, Role}, + pools::PoolFeeInfo, tokens::{CurrencyId, CustomMetadata, TrancheCurrency}, }; use frame_support::{ @@ -46,11 +52,13 @@ use crate::{self as pallet_pool_registry, Config}; type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; type Block = frame_system::mocking::MockBlock; -type TrancheId = [u8; 16]; pub const AUSD_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(1); const CURRENCY: Balance = 1_000_000_000_000_000_000; +pub type Balance = BalanceType; +type AccountId = u64; + parameter_types! { pub const BlockHashCount: u64 = 250; pub const SS58Prefix: u8 = 42; @@ -140,6 +148,7 @@ impl cfg_test_utils::mocks::nav::Config for Test { impl pallet_pool_system::Config for Test { type AssetRegistry = RegistryMock; + type AssetsUnderManagementNAV = FakeNav; type Balance = Balance; type BalanceRatio = Quantity; type ChallengeTime = ChallengeTime; @@ -156,13 +165,15 @@ impl pallet_pool_system::Config for Test { type MinEpochTimeLowerBound = MinEpochTimeLowerBound; type MinEpochTimeUpperBound = MinEpochTimeUpperBound; type MinUpdateDelay = MinUpdateDelay; - type NAV = FakeNav; + type OnEpochTransition = PoolFees; type PalletId = PoolPalletId; type PalletIndex = PoolPalletIndex; type Permission = PermissionsMock; type PoolCreateOrigin = EnsureSigned; type PoolCurrency = PoolCurrency; type PoolDeposit = PoolDeposit; + type PoolFees = PoolFees; + type PoolFeesNAV = PoolFees; type PoolId = PoolId; type Rate = Rate; type RuntimeChange = pallet_pool_system::pool_types::changes::PoolChangeProposal; @@ -176,7 +187,35 @@ impl pallet_pool_system::Config for Test { type WeightInfo = (); } -pub type Balance = u128; +impl pallet_mock_change_guard::Config for Test { + type Change = pallet_pool_fees::types::Change; + type ChangeId = H256; + type PoolId = PoolId; +} + +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = cfg_primitives::constants::MAX_POOL_FEES_PER_BUCKET; + pub const MaxFeesPerPool: u32 = cfg_primitives::constants::MAX_FEES_PER_POOL; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; +} + +impl pallet_pool_fees::Config for Test { + type Balance = Balance; + type ChangeGuard = MockChangeGuard; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = MockIsAdmin; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = PoolSystem; + type Rate = Rate; + type RuntimeChange = pallet_pool_fees::types::Change; + type RuntimeEvent = RuntimeEvent; + type Time = Timestamp; + type Tokens = OrmlTokens; +} parameter_types! { pub const One: u64 = 1; @@ -196,23 +235,31 @@ pub struct ModifyPoolMock { phantom: PhantomData, } -impl< - T: Config - + pallet_pool_registry::Config - + pallet_pool_system::Config, - > PoolMutate::PoolId> for ModifyPoolMock +impl PoolMutate::PoolId> for ModifyPoolMock +where + T: Config + + pallet_pool_system::Config + + pallet_pool_fees::Config, + ::PoolFees: PoolFeesBenchmarkHelper< + PoolId = u64, + PoolFeeInfo = PoolFeeInfo::Rate>, + >, { - type Balance = ::Balance; - type CurrencyId = ::CurrencyId; - type MaxTokenNameLength = ::MaxTokenNameLength; - type MaxTokenSymbolLength = ::MaxTokenSymbolLength; - type MaxTranches = ::MaxTranches; + type Balance = ::Balance; + type CurrencyId = ::CurrencyId; + type MaxTokenNameLength = ::MaxTokenNameLength; + type MaxTokenSymbolLength = ::MaxTokenSymbolLength; + type MaxTranches = ::MaxTranches; type PoolChanges = PoolChanges< ::Rate, ::MaxTokenNameLength, ::MaxTokenSymbolLength, ::MaxTranches, >; + type PoolFeeInput = ( + PoolFeeBucket, + <::PoolFees as PoolFeesBenchmarkHelper>::PoolFeeInfo, + ); type TrancheInput = TrancheInput< ::Rate, ::MaxTokenNameLength, @@ -226,9 +273,10 @@ impl< tranche_inputs: Vec, _currency: ::CurrencyId, _max_reserve: ::Balance, + pool_fees: Vec, ) -> DispatchResult { #[cfg(feature = "runtime-benchmarks")] - create_pool::(tranche_inputs.len() as u32, admin)?; + create_pool::(tranche_inputs.len() as u32, pool_fees.len() as u32, admin)?; Ok(()) } @@ -330,6 +378,11 @@ impl pallet_investments::Config for Test { type WeightInfo = (); } +impl pallet_mock_pre_conditions::Config for Test { + type Conditions = (AccountId, PoolId); + type Result = bool; +} + // Configure a mock runtime to test the pallet. frame_support::construct_runtime!( pub enum Test @@ -348,6 +401,9 @@ frame_support::construct_runtime!( Timestamp: pallet_timestamp::{Pallet, Call, Storage, Inherent}, Investments: pallet_investments::{Pallet, Call, Storage, Event}, MockWriteOffPolicy: pallet_mock_write_off_policy, + MockChangeGuard: pallet_mock_change_guard, + MockIsAdmin: cfg_mocks::pre_conditions::pallet, + PoolFees: pallet_pool_fees::{Pallet, Call, Storage, Event}, } ); @@ -430,9 +486,6 @@ impl pallet_balances::Config for Test { type WeightInfo = (); } -type AccountId = u64; -type PoolId = u64; - pub struct PermissionsMock {} impl cfg_traits::Permissions for PermissionsMock { diff --git a/pallets/pool-registry/src/tests.rs b/pallets/pool-registry/src/tests.rs index 6ec3ab9128..f50ed4e228 100644 --- a/pallets/pool-registry/src/tests.rs +++ b/pallets/pool-registry/src/tests.rs @@ -79,7 +79,8 @@ fn register_pool_and_set_metadata() { currency, max_reserve, metadata.clone(), - () + (), + vec![] )); let registered_metadata = PoolRegistry::get_pool_metadata(pool_id); diff --git a/pallets/pool-system/Cargo.toml b/pallets/pool-system/Cargo.toml index 9ee5500176..741ded9b09 100644 --- a/pallets/pool-system/Cargo.toml +++ b/pallets/pool-system/Cargo.toml @@ -17,7 +17,7 @@ parity-scale-codec = { version = "3.0.0", features = ["derive"], default-feature rev_slice = { version = "0.1.5", default-features = false } scale-info = { version = "2.3.0", default-features = false, features = ["derive"] } serde = { version = "1.0.119" } -strum = { version = "0.24", default-features = false, features = ["derive"] } +strum = { workspace = true } cfg-primitives = { path = "../../libs/primitives", default-features = false } cfg-traits = { path = "../../libs/traits", default-features = false } @@ -36,8 +36,10 @@ pallet-permissions = { path = "../../pallets/permissions", default-features = fa frame-benchmarking = { git = "https://github.com/paritytech/substrate", default-features = false, optional = true, branch = "polkadot-v0.9.43" } orml-asset-registry = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, optional = true, branch = "polkadot-v0.9.43" } pallet-investments = { path = "../../pallets/investments", default-features = false, optional = true } +pallet-pool-fees = { workspace = true, default-features = false, optional = true } [dev-dependencies] +cfg-mocks = { workspace = true, default-features = true } cfg-test-utils = { path = "../../libs/test-utils", default-features = true } orml-asset-registry = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = false, branch = "polkadot-v0.9.43" } orml-tokens = { git = "https://github.com/open-web3-stack/open-runtime-module-library", default-features = true, branch = "polkadot-v0.9.43" } @@ -53,6 +55,7 @@ xcm = { git = "https://github.com/paritytech/polkadot", default-features = false [features] default = ["std"] runtime-benchmarks = [ + "cfg-mocks/runtime-benchmarks", "cfg-primitives/runtime-benchmarks", "cfg-test-utils/runtime-benchmarks", "cfg-traits/runtime-benchmarks", @@ -63,6 +66,7 @@ runtime-benchmarks = [ "orml-asset-registry/runtime-benchmarks", "pallet-investments/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "pallet-timestamp/runtime-benchmarks", "sp-runtime/runtime-benchmarks", ] @@ -77,6 +81,7 @@ std = [ "log/std", "orml-traits/std", "pallet-permissions/std", + "pallet-pool-fees/std", "pallet-timestamp/std", "rev_slice/std", "scale-info/std", @@ -87,6 +92,7 @@ std = [ "strum/std", ] try-runtime = [ + "cfg-mocks/try-runtime", "cfg-primitives/try-runtime", "cfg-traits/try-runtime", "cfg-types/try-runtime", @@ -94,6 +100,7 @@ try-runtime = [ "frame-system/try-runtime", "lazy_static/spin_no_std", "pallet-permissions/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-timestamp/try-runtime", "sp-runtime/try-runtime", ] diff --git a/pallets/pool-system/src/benchmarking.rs b/pallets/pool-system/src/benchmarking.rs index 9d7c45fe64..0d6fc39a6f 100644 --- a/pallets/pool-system/src/benchmarking.rs +++ b/pallets/pool-system/src/benchmarking.rs @@ -13,9 +13,14 @@ //! Module provides benchmarking for Loan Pallet use cfg_primitives::PoolEpochId; -use cfg_traits::{investments::TrancheCurrency as _, UpdateState}; +use cfg_traits::{ + benchmarking::PoolFeesBenchmarkHelper, + fee::{PoolFeeBucket, PoolFees}, + investments::TrancheCurrency as _, + UpdateState, +}; use cfg_types::{ - pools::TrancheMetadata, + pools::{PoolFeeInfo, TrancheMetadata}, tokens::{CurrencyId, CustomMetadata, TrancheCurrency}, }; use frame_benchmarking::{account, benchmarks, impl_benchmark_test_suite}; @@ -54,17 +59,24 @@ benchmarks! { T::AccountId: EncodeLike<::AccountId>, <::Lookup as sp_runtime::traits::StaticLookup>::Source: From<::AccountId>, - T::NAV: PoolNAV, + T::AssetsUnderManagementNAV: PoolNAV<::PoolId, ::Balance, RuntimeOrigin = T::RuntimeOrigin>, T::Permission: Permissions, - >::ClassId: From, + ::PoolId, ::Balance>>::ClassId: From, + T: pallet_pool_fees::Config, + T::PoolFees: PoolFeesBenchmarkHelper< + PoolId = ::PoolId, + PoolFeeInfo = PoolFeeInfo::Balance, ::Rate>, + >, } set_max_reserve { + let m in 0..T::PoolFees::get_max_fees_per_bucket(); + let admin: T::AccountId = create_admin::(0); let caller: T::AccountId = create_admin::(1); let max_reserve = MAX_RESERVE / 2; prepare_asset_registry::(); - create_pool::(1, admin.clone())?; + create_pool::(1, m, admin.clone())?; set_liquidity_admin::(caller.clone())?; }: set_max_reserve(RawOrigin::Signed(caller), POOL, max_reserve) verify { @@ -74,9 +86,11 @@ benchmarks! { close_epoch_no_orders { let admin: T::AccountId = create_admin::(0); let n in 1..T::MaxTranches::get(); + let m in 1..T::PoolFees::get_max_fees_per_bucket(); + prepare_asset_registry::(); - create_pool::(n, admin.clone())?; - T::NAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; + create_pool::(n, m, admin.clone())?; + T::AssetsUnderManagementNAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; unrestrict_epoch_close::(); }: close_epoch(RawOrigin::Signed(admin.clone()), POOL) verify { @@ -86,12 +100,14 @@ benchmarks! { close_epoch_no_execution { let n in 1..T::MaxTranches::get(); // number of tranches + let m in 0..T::PoolFees::get_max_fees_per_bucket(); let admin: T::AccountId = create_admin::(0); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; - T::NAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; + create_pool::(n, m, admin.clone())?; + T::AssetsUnderManagementNAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; unrestrict_epoch_close::(); + let investment = MAX_RESERVE * 2; let investor = create_investor::(0, TRANCHE, None)?; let origin = RawOrigin::Signed(investor.clone()).into(); @@ -104,10 +120,13 @@ benchmarks! { close_epoch_execute { let n in 1..T::MaxTranches::get(); // number of tranches + let m in 0..T::PoolFees::get_max_fees_per_bucket(); + let admin: T::AccountId = create_admin::(0); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; - T::NAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; + create_pool::(n, m, admin.clone())?; + + T::AssetsUnderManagementNAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; unrestrict_epoch_close::(); let investment = MAX_RESERVE / 2; let investor = create_investor::(0, TRANCHE, None)?; @@ -121,15 +140,19 @@ benchmarks! { submit_solution { let n in 1..T::MaxTranches::get(); // number of tranches + let m in 0..T::PoolFees::get_max_fees_per_bucket(); + let admin: T::AccountId = create_admin::(0); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; - T::NAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; + create_pool::(n, m, admin.clone())?; + T::AssetsUnderManagementNAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; unrestrict_epoch_close::(); + let investment = MAX_RESERVE * 2; let investor = create_investor::(0, TRANCHE, None)?; let origin = RawOrigin::Signed(investor.clone()).into(); pallet_investments::Pallet::::update_invest_order(origin, TrancheCurrency::generate(POOL, get_tranche_id::(TRANCHE)), investment)?; + let admin_origin = RawOrigin::Signed(admin.clone()).into(); Pallet::::close_epoch(admin_origin, POOL)?; let default_solution = Pallet::::epoch_targets(POOL).unwrap().best_submission; @@ -148,15 +171,20 @@ benchmarks! { execute_epoch { let n in 1..T::MaxTranches::get(); // number of tranches + let m in 0..T::PoolFees::get_max_fees_per_bucket(); + let admin: T::AccountId = create_admin::(0); prepare_asset_registry::(); - create_pool::(n, admin.clone())?; - T::NAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; + create_pool::(n, m, admin.clone())?; + + T::AssetsUnderManagementNAV::initialise(RawOrigin::Signed(admin.clone()).into(), POOL, 0.into())?; unrestrict_epoch_close::(); + let investment = MAX_RESERVE * 2; let investor = create_investor::(0, TRANCHE, None)?; let origin = RawOrigin::Signed(investor.clone()).into(); pallet_investments::Pallet::::update_invest_order(origin, TrancheCurrency::generate(POOL, get_tranche_id::(TRANCHE)), investment)?; + let admin_origin = RawOrigin::Signed(admin.clone()).into(); Pallet::::close_epoch(admin_origin, POOL)?; let default_solution = Pallet::::epoch_targets(POOL).unwrap().best_submission; @@ -211,7 +239,7 @@ pub fn unrestrict_epoch_close>() { } pub fn get_pool>() -> PoolDetailsOf { - Pallet::::pool(T::PoolId::from(POOL)).unwrap() + Pallet::::pool(POOL).unwrap() } pub fn get_tranche_id>(index: TrancheIndex) -> T::TrancheId { @@ -240,12 +268,12 @@ where investor.clone(), Role::PoolRole(PoolRole::TrancheInvestor(tranche_id, 0x0FFF_FFFF_FFFF_FFFF)), )?; - T::Currency::deposit_creating(&investor.clone().into(), ED); - T::Tokens::mint_into(AUSD_CURRENCY_ID, &investor.clone().into(), MINT_AMOUNT)?; + T::Currency::deposit_creating(&investor.clone(), ED); + T::Tokens::mint_into(AUSD_CURRENCY_ID, &investor.clone(), MINT_AMOUNT)?; if let Some(amount) = with_tranche_tokens { T::Tokens::mint_into( CurrencyId::Tranche(POOL, tranche_id), - &investor.clone().into(), + &investor.clone(), amount, )?; } @@ -259,7 +287,7 @@ where { let admin: T::AccountId = account("admin", id, 0); let mint_amount = T::PoolDeposit::get() * 2 + ED; - T::Currency::deposit_creating(&admin.clone().into(), mint_amount); + T::Currency::deposit_creating(&admin.clone(), mint_amount); admin } @@ -274,10 +302,15 @@ where ) } -pub fn create_pool>( - num_tranches: u32, - caller: T::AccountId, -) -> DispatchResult { +pub fn create_pool(num_tranches: u32, num_pool_fees: u32, caller: T::AccountId) -> DispatchResult +where + T: Config, + T: pallet_pool_fees::Config, + T::PoolFees: PoolFeesBenchmarkHelper< + PoolId = ::PoolId, + PoolFeeInfo = PoolFeeInfo::Balance, ::Rate>, + >, +{ let tranches = build_bench_input_tranches::(num_tranches); Pallet::::create( caller.clone(), @@ -286,6 +319,10 @@ pub fn create_pool>( pub fn get_scheduled_update>( ) -> ScheduledUpdateDetails { - Pallet::::scheduled_update(T::PoolId::from(POOL)).unwrap() + Pallet::::scheduled_update(POOL).unwrap() } pub fn assert_input_tranches_match( diff --git a/pallets/pool-system/src/impls.rs b/pallets/pool-system/src/impls.rs index daefa4a0d5..984d2d53e5 100644 --- a/pallets/pool-system/src/impls.rs +++ b/pallets/pool-system/src/impls.rs @@ -12,10 +12,11 @@ use cfg_traits::{ changes::ChangeGuard, + fee::{PoolFeeBucket, PoolFees}, investments::{InvestmentAccountant, TrancheCurrency}, CurrencyPair, PoolUpdateGuard, PriceValue, TrancheTokenPrice, UpdateState, }; -use cfg_types::{epoch::EpochState, investments::InvestmentInfo}; +use cfg_types::{epoch::EpochState, investments::InvestmentInfo, pools::PoolFeeInfo}; use frame_support::traits::{ tokens::{Fortitude, Precision, Preservation}, Contains, @@ -70,7 +71,7 @@ impl TrancheTokenPrice for Pallet { // Get cached nav as calculating current nav would be too computationally // expensive - let (nav, nav_last_updated) = T::NAV::nav(pool_id)?; + let (nav, nav_last_updated) = T::AssetsUnderManagementNAV::nav(pool_id)?; let total_assets = pool.reserve.total.ensure_add(nav).ok()?; let tranche_index: usize = pool @@ -108,6 +109,10 @@ impl PoolMutate for Pallet { type MaxTokenSymbolLength = T::MaxTokenSymbolLength; type MaxTranches = T::MaxTranches; type PoolChanges = PoolChangesOf; + type PoolFeeInput = ( + PoolFeeBucket, + PoolFeeInfo, + ); type TrancheInput = TrancheInput; fn create( @@ -117,6 +122,7 @@ impl PoolMutate for Pallet { tranche_inputs: Vec>, currency: T::CurrencyId, max_reserve: T::Balance, + pool_fees: Vec, ) -> DispatchResult { // A single pool ID can only be used by one owner. ensure!(!Pool::::contains_key(pool_id), Error::::PoolInUse); @@ -177,6 +183,10 @@ impl PoolMutate for Pallet { .map_err(|_| Error::::FailedToRegisterTrancheMetadata)?; } + for (fee_bucket, pool_fee) in pool_fees.into_iter() { + T::PoolFees::add_fee(pool_id, fee_bucket, pool_fee)?; + } + let pool_details = PoolDetails { currency, tranches, @@ -529,6 +539,9 @@ mod benchmarks_utils { ], POOL_CURRENCY, FUNDS.into(), + // NOTE: Genesis pool fees missing per default, could be added via ::add_pool_fees(..) + vec![], )); } } @@ -566,7 +579,7 @@ mod benchmarks_utils { .of_tranche(); frame_support::assert_ok!(T::Investments::update_investment( &investor, - T::TrancheCurrency::generate(pool_id.into(), tranche), + T::TrancheCurrency::generate(pool_id, tranche), FUNDS.into(), )); diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index c54d5e8fcd..d4d311d4ea 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -177,11 +177,13 @@ impl Default for Release { #[frame_support::pallet] pub mod pallet { use cfg_traits::{ + fee::{PoolFeeBucket, PoolFees}, investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, - PoolUpdateGuard, + EpochTransitionHook, PoolUpdateGuard, }; use cfg_types::{ orders::{FulfillmentWithPrice, TotalOrder}, + pools::PoolFeeInfo, tokens::CustomMetadata, }; use frame_support::{ @@ -298,7 +300,11 @@ pub mod pallet { Error = DispatchError, >; - type NAV: PoolNAV; + /// The provider for the positive NAV + type AssetsUnderManagementNAV: PoolNAV; + + /// The provider for the negative NAV + type PoolFeesNAV: PoolNAV; type TrancheCurrency: Into + Clone @@ -317,6 +323,24 @@ pub mod pallet { type Time: TimeAsSecs; + /// Add pool fees + type PoolFees: PoolFees< + FeeInfo = PoolFeeInfo< + ::AccountId, + Self::Balance, + Self::Rate, + >, + PoolId = Self::PoolId, + >; + + /// Epoch transition hook required for Pool Fees + type OnEpochTransition: EpochTransitionHook< + Balance = Self::Balance, + PoolId = Self::PoolId, + Time = Seconds, + Error = DispatchError, + >; + /// Challenge time #[pallet::constant] type ChallengeTime: Get<::BlockNumber>; @@ -365,7 +389,7 @@ pub mod pallet { type WeightInfo: WeightInfo; } - const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -524,6 +548,9 @@ pub mod pallet { ChangeNotFound, /// The external change was found for is not ready yet to be released. ChangeNotReady, + /// The PoolFeesNAV exceeds the sum of the AUM and the total reserve of + /// the pool + NegativeBalanceSheet, } #[pallet::call] @@ -535,7 +562,7 @@ pub mod pallet { /// given to the pool creator by default, and must be /// added with the Permissions pallet before this /// extrinsic can be called. - #[pallet::weight(T::WeightInfo::set_max_reserve())] + #[pallet::weight(T::WeightInfo::set_max_reserve(T::PoolFees::get_max_fees_per_bucket()))] #[pallet::call_index(0)] pub fn set_max_reserve( origin: OriginFor, @@ -578,9 +605,9 @@ pub mod pallet { /// submission period, partial executions can be submitted /// to be scored, and the best-scoring solution will /// eventually be executed. See `submit_solution`. - #[pallet::weight(T::WeightInfo::close_epoch_no_orders(T::MaxTranches::get()) - .max(T::WeightInfo::close_epoch_no_execution(T::MaxTranches::get())) - .max(T::WeightInfo::close_epoch_execute(T::MaxTranches::get())))] + #[pallet::weight(T::WeightInfo::close_epoch_no_orders(T::MaxTranches::get(), T::PoolFees::get_max_fees_per_bucket()) + .max(T::WeightInfo::close_epoch_no_execution(T::MaxTranches::get(), T::PoolFees::get_max_fees_per_bucket())) + .max(T::WeightInfo::close_epoch_execute(T::MaxTranches::get(), T::PoolFees::get_max_fees_per_bucket())))] #[transactional] #[pallet::call_index(1)] pub fn close_epoch(origin: OriginFor, pool_id: T::PoolId) -> DispatchResultWithPostInfo { @@ -599,21 +626,37 @@ pub mod pallet { Error::::MinEpochTimeHasNotPassed ); - let (nav, nav_last_updated) = T::NAV::nav(pool_id).ok_or(Error::::NoNAV)?; - + // Get positive NAV from AUM + let (nav_aum, aum_last_updated) = + T::AssetsUnderManagementNAV::nav(pool_id).ok_or(Error::::NoNAV)?; ensure!( - now.saturating_sub(nav_last_updated) <= pool.parameters.max_nav_age, + now.saturating_sub(aum_last_updated) <= pool.parameters.max_nav_age, Error::::NAVTooOld ); + // Calculate fees to get negative NAV + T::OnEpochTransition::on_closing_mutate_reserve( + pool_id, + nav_aum, + &mut pool.reserve.total, + )?; + let (nav_fees, fees_last_updated) = + T::PoolFeesNAV::nav(pool_id).ok_or(Error::::NoNAV)?; + ensure!( + now.saturating_sub(fees_last_updated) <= pool.parameters.max_nav_age, + Error::::NAVTooOld + ); + let nav = Nav::new(nav_aum, nav_fees); + let nav_total = nav + .total(pool.reserve.total) + .map_err(|_| Error::::NegativeBalanceSheet)?; let submission_period_epoch = pool.epoch.current; - let total_assets = nav.ensure_add(pool.reserve.total)?; pool.start_next_epoch(now)?; let epoch_tranche_prices = pool .tranches - .calculate_prices::(total_assets, now)?; + .calculate_prices::(nav_total, now)?; // If closing the epoch would wipe out a tranche, the close is invalid. // TODO: This should instead put the pool into an error state @@ -631,7 +674,6 @@ pub mod pallet { // Get the orders let orders = Self::summarize_orders(&pool.tranches, &epoch_tranche_prices)?; - if orders.all_are_zero() { pool.tranches.combine_with_mut_residual_top( &epoch_tranche_prices, @@ -657,6 +699,7 @@ pub mod pallet { .num_tranches() .try_into() .expect("MaxTranches is u32. qed."), + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), )) .into()); } @@ -683,10 +726,8 @@ pub mod pallet { )?; let mut epoch = EpochExecutionInfo { - epoch: submission_period_epoch, nav, - reserve: pool.reserve.total, - max_reserve: pool.reserve.max, + epoch: submission_period_epoch, tranches: EpochExecutionTranches::new(epoch_tranches), best_submission: None, challenge_period_end: None, @@ -713,6 +754,7 @@ pub mod pallet { .num_tranches() .try_into() .expect("MaxTranches is u32. qed."), + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), )) .into()) } else { @@ -735,6 +777,7 @@ pub mod pallet { .num_tranches() .try_into() .expect("MaxTranches is u32. qed."), + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), )) .into()) } @@ -751,7 +794,10 @@ pub mod pallet { /// Once a valid solution has been submitted, the /// challenge time begins. The pool can be executed once /// the challenge time has expired. - #[pallet::weight(T::WeightInfo::submit_solution(T::MaxTranches::get()))] + #[pallet::weight(T::WeightInfo::submit_solution( + T::MaxTranches::get(), + T::PoolFees::get_max_fees_per_bucket() + ))] #[pallet::call_index(2)] pub fn submit_solution( origin: OriginFor, @@ -792,6 +838,7 @@ pub mod pallet { .num_tranches() .try_into() .expect("MaxTranches is u32. qed."), + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), )) .into()) }) @@ -804,7 +851,10 @@ pub mod pallet { /// * Updates the portion of the reserve and loan balance assigned to /// each tranche, based on the investments and redemptions to those /// tranches. - #[pallet::weight(T::WeightInfo::execute_epoch(T::MaxTranches::get()))] + #[pallet::weight(T::WeightInfo::execute_epoch( + T::MaxTranches::get(), + T::PoolFees::get_max_fees_per_bucket() + ))] #[pallet::call_index(3)] pub fn execute_epoch( origin: OriginFor, @@ -868,7 +918,11 @@ pub mod pallet { // This kills the epoch info in storage. // See: https://github.com/paritytech/substrate/blob/bea8f32e7807233ab53045fe8214427e0f136230/frame/support/src/storage/generator/map.rs#L269-L284 *epoch_info = None; - Ok(Some(T::WeightInfo::execute_epoch(num_tranches)).into()) + Ok(Some(T::WeightInfo::execute_epoch( + num_tranches, + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), + )) + .into()) }) } } @@ -927,8 +981,8 @@ pub mod pallet { PoolState::Unhealthy(states) => EpochSolution::score_solution_unhealthy( solution, &epoch.tranches, - epoch.reserve, - epoch.max_reserve, + pool_id.reserve.total, + pool_id.reserve.max, &states, ), } @@ -955,7 +1009,7 @@ pub mod pallet { >(&epoch.tranches, solution) .map_err(|e| { // In case we have an underflow in the calculation, there - // is not enough balance in the tranches to realize the redeemptions. + // is not enough balance in the tranches to realize the redemptions. // We convert this at the pool level into an InsufficientCurrency error. if e == DispatchError::Arithmetic(ArithmeticError::Underflow) { Error::::InsufficientCurrency @@ -965,7 +1019,7 @@ pub mod pallet { })?; let currency_available: T::Balance = acc_invest - .checked_add(&epoch.reserve) + .checked_add(&pool.reserve.total) .ok_or(Error::::InvalidSolution)?; let new_reserve = currency_available @@ -1126,7 +1180,8 @@ pub mod pallet { // setup. if let Some(old_tranches) = old_tranches { // For now, adding or removing tranches is not allowed, unless it's on pool - // creation. TODO: allow adding tranches as most senior, and removing most + // creation. + // TODO: allow adding tranches as most senior, and removing most // senior and empty (debt+reserve=0) tranches ensure!( new_tranches.len() == old_tranches.num_tranches(), @@ -1142,6 +1197,8 @@ pub mod pallet { epoch: &EpochExecutionInfoOf, solution: &[TrancheSolution], ) -> DispatchResult { + T::OnEpochTransition::on_execution_pre_fulfillments(pool_id)?; + pool.reserve.deposit_from_epoch(&epoch.tranches, solution)?; for (tranche, solution) in epoch.tranches.residual_top_slice().iter().zip(solution) { @@ -1152,6 +1209,7 @@ pub mod pallet { price: tranche.price, }, )?; + T::Investments::redeem_fulfillment( tranche.currency, FulfillmentWithPrice { @@ -1164,7 +1222,7 @@ pub mod pallet { pool.execute_previous_epoch()?; let executed_amounts = epoch.tranches.fulfillment_cash_flows(solution)?; - let total_assets = pool.reserve.total.ensure_add(epoch.nav)?; + let total_assets = epoch.nav.total(pool.reserve.total)?; let tranche_ratios = epoch.tranches.combine_with_residual_top( &executed_amounts, |tranche, &(invest, redeem)| { @@ -1178,7 +1236,7 @@ pub mod pallet { pool.tranches.rebalance_tranches( T::Time::now(), pool.reserve.total, - epoch.nav, + epoch.nav.nav_aum, tranche_ratios.as_slice(), &executed_amounts, )?; diff --git a/pallets/pool-system/src/mock.rs b/pallets/pool-system/src/mock.rs index 2d4f93ec8e..021118d2db 100644 --- a/pallets/pool-system/src/mock.rs +++ b/pallets/pool-system/src/mock.rs @@ -9,15 +9,20 @@ // 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. -use cfg_primitives::{Balance, BlockNumber, CollectionId, PoolId, TrancheId}; +use cfg_mocks::{pallet_mock_change_guard, pallet_mock_pre_conditions}; +use cfg_primitives::{ + Balance, BlockNumber, CollectionId, PoolFeeId, PoolId, TrancheId, SECONDS_PER_YEAR, +}; pub use cfg_primitives::{PoolEpochId, TrancheWeight}; use cfg_traits::{ + fee::PoolFeeBucket, investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, Millis, Permissions as PermissionsT, PoolUpdateGuard, PreConditions, Seconds, }; pub use cfg_types::fixed_point::{Quantity, Rate}; use cfg_types::{ permissions::{PermissionRoles, PermissionScope, PoolRole, Role, UNION}, + pools::{PoolFeeAmount, PoolFeeEditor, PoolFeeType}, time::TimeProvider, tokens::{CurrencyId, CustomMetadata, TrancheCurrency}, }; @@ -25,13 +30,15 @@ use frame_support::{ assert_ok, parameter_types, sp_std::marker::PhantomData, traits::{Contains, GenesisBuild, Hooks, PalletInfoAccess, SortedMembers}, - Blake2_128, StorageHasher, + Blake2_128, PalletId, StorageHasher, }; use frame_system as system; use frame_system::{EnsureSigned, EnsureSignedBy}; use orml_traits::{asset_registry::AssetMetadata, parameter_type_with_key}; +use pallet_pool_fees::PoolFeeInfoOf; use pallet_restricted_tokens::TransferDetails; use parity_scale_codec::Encode; +use sp_arithmetic::FixedPointNumber; use sp_core::H256; use sp_runtime::{ testing::Header, @@ -51,6 +58,75 @@ pub type MockAccountId = u64; pub const AUSD_CURRENCY_ID: CurrencyId = CurrencyId::ForeignAsset(1); +pub const CURRENCY: Balance = 1_000_000_000_000_000_000; +pub const JUNIOR_TRANCHE_INDEX: u8 = 0u8; +pub const SENIOR_TRANCHE_INDEX: u8 = 1u8; +pub const START_DATE: u64 = 1640991600; // 2022.01.01 +pub const SECONDS: u64 = 1000; + +pub const DEFAULT_POOL_ID: PoolId = 0; +pub const DEFAULT_POOL_OWNER: MockAccountId = 10; +pub const DEFAULT_POOL_MAX_RESERVE: Balance = 10_000 * CURRENCY; + +pub const DEFAULT_FEE_EDITOR: PoolFeeEditor = PoolFeeEditor::Account(100); +pub const DEFAULT_FEE_DESTINATION: MockAccountId = 101; +pub const POOL_FEE_FIXED_RATE_MULTIPLIER: u64 = SECONDS_PER_YEAR / 12; +pub const POOL_FEE_CHARGED_AMOUNT_PER_SECOND: Balance = 1000; + +pub fn default_pool_fees() -> Vec> { + vec![ + PoolFeeInfoOf:: { + destination: DEFAULT_FEE_DESTINATION, + editor: DEFAULT_FEE_EDITOR, + fee_type: PoolFeeType::Fixed { + // For simplicity, we take 10% per block to simulate fees on a per-block basis + // because advancing one full year takes too long + limit: PoolFeeAmount::ShareOfPortfolioValuation(Rate::saturating_from_rational( + POOL_FEE_FIXED_RATE_MULTIPLIER, + 10, + )), + }, + }, + PoolFeeInfoOf:: { + destination: DEFAULT_FEE_DESTINATION, + editor: DEFAULT_FEE_EDITOR, + fee_type: PoolFeeType::ChargedUpTo { + limit: PoolFeeAmount::AmountPerSecond(POOL_FEE_CHARGED_AMOUNT_PER_SECOND), + }, + }, + ] +} + +pub fn assert_pending_fees( + pool_id: PoolId, + fees: Vec>, + pending_disbursement_payable: Vec<(Balance, Balance, Option)>, +) { + let active_fees = pallet_pool_fees::ActiveFees::::get(pool_id, PoolFeeBucket::Top); + + assert_eq!(fees.len(), pending_disbursement_payable.len()); + assert_eq!(fees.len(), active_fees.len()); + + for i in 0..fees.len() { + let active_fee = active_fees.get(i).unwrap(); + let fee = fees.get(i).unwrap(); + let (pending, disbursement, payable) = pending_disbursement_payable.get(i).unwrap(); + + assert_eq!(active_fee.destination, fee.destination); + assert_eq!(active_fee.editor, fee.editor); + assert_eq!(active_fee.amounts.fee_type, fee.fee_type); + assert_eq!(active_fee.amounts.pending, *pending); + assert_eq!(active_fee.amounts.disbursement, *disbursement); + assert!(match fee.fee_type { + PoolFeeType::ChargedUpTo { .. } => matches!( + active_fee.amounts.payable, + cfg_types::pools::PayableFeeAmount::UpTo(p) if p == payable.unwrap() + ), + _ => payable.is_none(), + }); + } +} + // Configure a mock runtime to test the pallet. frame_support::construct_runtime!( pub enum Runtime where @@ -68,6 +144,9 @@ frame_support::construct_runtime!( Balances: pallet_balances::{Pallet, Storage, Event}, ParachainInfo: parachain_info::{Pallet, Storage}, Investments: pallet_investments::{Pallet, Call, Storage, Event}, + MockChangeGuard: pallet_mock_change_guard, + MockIsAdmin: cfg_mocks::pre_conditions::pallet, + PoolFees: pallet_pool_fees::{Pallet, Call, Storage, Event}, } ); @@ -290,6 +369,41 @@ impl PreConditions for Always { } } +impl pallet_mock_change_guard::Config for Runtime { + type Change = pallet_pool_fees::types::Change; + type ChangeId = H256; + type PoolId = PoolId; +} + +impl pallet_mock_pre_conditions::Config for Runtime { + type Conditions = (MockAccountId, PoolId); + type Result = bool; +} + +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = cfg_primitives::constants::MAX_POOL_FEES_PER_BUCKET; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; + pub const MaxFeesPerPool: u32 = cfg_primitives::constants::MAX_FEES_PER_POOL; +} + +impl pallet_pool_fees::Config for Runtime { + type Balance = Balance; + type ChangeGuard = MockChangeGuard; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = MockIsAdmin; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = PoolSystem; + type Rate = Rate; + type RuntimeChange = pallet_pool_fees::types::Change; + type RuntimeEvent = RuntimeEvent; + type Time = Timestamp; + type Tokens = Tokens; +} + parameter_types! { pub const PoolPalletId: frame_support::PalletId = cfg_types::ids::POOLS_PALLET_ID; @@ -322,6 +436,7 @@ parameter_types! { impl Config for Runtime { type AssetRegistry = RegistryMock; + type AssetsUnderManagementNAV = FakeNav; type Balance = Balance; type BalanceRatio = Quantity; type ChallengeTime = ChallengeTime; @@ -338,13 +453,15 @@ impl Config for Runtime { type MinEpochTimeLowerBound = MinEpochTimeLowerBound; type MinEpochTimeUpperBound = MinEpochTimeUpperBound; type MinUpdateDelay = MinUpdateDelay; - type NAV = FakeNav; + type OnEpochTransition = PoolFees; type PalletId = PoolPalletId; type PalletIndex = PoolPalletIndex; type Permission = Permissions; type PoolCreateOrigin = EnsureSigned; type PoolCurrency = PoolCurrency; type PoolDeposit = PoolDeposit; + type PoolFees = PoolFees; + type PoolFeesNAV = PoolFees; type PoolId = PoolId; type Rate = Rate; type RuntimeChange = PoolChangeProposal; @@ -418,8 +535,6 @@ impl cfg_test_utils::mocks::nav::Config for Runtime { type PoolId = PoolId; } -pub const CURRENCY: Balance = 1_000_000_000_000_000_000; - fn create_tranche_id(pool: u64, tranche: u64) -> [u8; 16] { let hash_input = (tranche, pool).encode(); Blake2_128::hash(&hash_input) @@ -429,13 +544,6 @@ parameter_types! { pub JuniorTrancheId: [u8; 16] = create_tranche_id(0, 0); pub SeniorTrancheId: [u8; 16] = create_tranche_id(0, 1); } -pub const JUNIOR_TRANCHE_INDEX: u8 = 0u8; -pub const SENIOR_TRANCHE_INDEX: u8 = 1u8; -pub const START_DATE: u64 = 1640991600; // 2022.01.01 -pub const SECONDS: u64 = 1000; - -pub const DEFAULT_POOL_ID: PoolId = 0; -pub const DEFAULT_POOL_OWNER: MockAccountId = 10; // Build genesis storage according to the mock runtime. pub fn new_test_ext() -> sp_io::TestExternalities { @@ -446,7 +554,7 @@ pub fn new_test_ext() -> sp_io::TestExternalities { orml_tokens::GenesisConfig:: { balances: (0..20) .into_iter() - .map(|idx| (idx, AUSD_CURRENCY_ID, 1000 * CURRENCY)) + .map(|idx| (idx, AUSD_CURRENCY_ID, 2000 * CURRENCY)) .collect(), } .assimilate_storage(&mut t) diff --git a/pallets/pool-system/src/solution.rs b/pallets/pool-system/src/solution.rs index 4bdfef4a11..a22a73656f 100644 --- a/pallets/pool-system/src/solution.rs +++ b/pallets/pool-system/src/solution.rs @@ -109,6 +109,26 @@ where Unhealthy(UnhealthySolution), } +#[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +pub struct Nav { + pub nav_aum: Balance, + pub nav_fees: Balance, +} + +impl Nav { + pub fn new(nav_aum: Balance, nav_fees: Balance) -> Self { + Self { nav_fees, nav_aum } + } + + pub fn total(&self, reserve: Balance) -> Result { + self.nav_aum + .ensure_add(reserve)? + .ensure_sub(self.nav_fees) + .map_err(Into::into) + } +} + /// The information for a currently executing epoch #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct EpochExecutionInfo< @@ -123,9 +143,7 @@ pub struct EpochExecutionInfo< MaxTranches: Get, { pub epoch: EpochId, - pub nav: Balance, - pub reserve: Balance, - pub max_reserve: Balance, + pub nav: Nav, pub tranches: EpochExecutionTranches, pub best_submission: Option>, diff --git a/pallets/pool-system/src/tests/mod.rs b/pallets/pool-system/src/tests/mod.rs index 66499205c2..74d27a9477 100644 --- a/pallets/pool-system/src/tests/mod.rs +++ b/pallets/pool-system/src/tests/mod.rs @@ -10,7 +10,11 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::{investments::TrancheCurrency as TrancheCurrencyT, PoolMutate, TrancheTokenPrice}; +use cfg_primitives::{constants::SECONDS_PER_YEAR, Balance}; +use cfg_traits::{ + fee::PoolFeeBucket, investments::TrancheCurrency as TrancheCurrencyT, PoolMutate, PoolNAV, + TrancheTokenPrice, +}; use cfg_types::{ epoch::EpochState, fixed_point::Rate, @@ -35,7 +39,7 @@ use crate::{ calculate_risk_buffers, EpochExecutionTranche, EpochExecutionTranches, Tranche, TrancheInput, TrancheSolution, TrancheType, Tranches, }, - BoundedVec, Change, Config, EpochExecution, EpochExecutionInfo, Error, Pool, PoolState, + BoundedVec, Change, Config, EpochExecution, EpochExecutionInfo, Error, Nav, Pool, PoolState, UnhealthyState, }; @@ -81,6 +85,7 @@ pub mod util { ], AUSD_CURRENCY_ID, 0, + vec![], ) .unwrap(); } @@ -182,9 +187,7 @@ fn core_constraints_currency_available_cant_cover_redemptions() { let epoch = EpochExecutionInfo { epoch: Zero::zero(), - nav: 0, - reserve: pool.reserve.total, - max_reserve: pool.reserve.max, + nav: Nav::new(0, 0), tranches: epoch_tranches, best_submission: None, challenge_period_end: None, @@ -266,9 +269,7 @@ fn pool_constraints_pool_reserve_above_max_reserve() { let epoch = EpochExecutionInfo { epoch: Zero::zero(), - nav: 90, - reserve: pool.reserve.total, - max_reserve: pool.reserve.max, + nav: Nav::new(90, 0), tranches: epoch_tranches, best_submission: None, challenge_period_end: None, @@ -366,9 +367,7 @@ fn pool_constraints_tranche_violates_risk_buffer() { let epoch = EpochExecutionInfo { epoch: Zero::zero(), - nav: 0, - reserve: pool.reserve.total, - max_reserve: pool.reserve.max, + nav: Nav::new(0, 0), tranches: epoch_tranches, best_submission: None, challenge_period_end: None, @@ -481,9 +480,7 @@ fn pool_constraints_pass() { let epoch = EpochExecutionInfo { epoch: Zero::zero(), - nav: 145, - reserve: pool.reserve.total, - max_reserve: pool.reserve.max, + nav: Nav::new(145, 0), tranches: epoch_tranches, best_submission: None, challenge_period_end: None, @@ -517,9 +514,8 @@ fn epoch() { let borrower = 3; // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( pool_owner.clone(), @@ -548,6 +544,7 @@ fn epoch() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); assert_ok!(Investments::update_invest_order( RuntimeOrigin::signed(0), @@ -754,9 +751,8 @@ fn submission_period() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10u128, 100u128) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( pool_owner.clone(), @@ -785,6 +781,7 @@ fn submission_period() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); assert_ok!(Investments::update_invest_order( RuntimeOrigin::signed(0), @@ -942,9 +939,8 @@ fn execute_info_removed_after_epoch_execute() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( @@ -974,6 +970,7 @@ fn execute_info_removed_after_epoch_execute() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); // Force min_epoch_time to 0 without using update @@ -1045,6 +1042,7 @@ fn pool_updates_should_be_constrained() { }], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); crate::Pool::::try_mutate(0, |maybe_pool| -> Result<(), ()> { @@ -1151,9 +1149,8 @@ fn tranche_ids_are_unique() { } }; - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( 0, @@ -1204,6 +1201,7 @@ fn tranche_ids_are_unique() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); assert_ok!(PoolSystem::create( @@ -1255,6 +1253,7 @@ fn tranche_ids_are_unique() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); let pool_ids_0 = PoolSystem::pool(pool_id_0) @@ -1293,6 +1292,7 @@ fn same_pool_id_not_possible() { },], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); assert_noop!( @@ -1310,6 +1310,7 @@ fn same_pool_id_not_possible() { },], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], ), Error::::PoolInUse ); @@ -1320,9 +1321,8 @@ fn same_pool_id_not_possible() { fn valid_tranche_structure_is_enforced() { new_test_ext().execute_with(|| { let pool_id_0 = 0u64; - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_noop!( @@ -1375,6 +1375,8 @@ fn valid_tranche_structure_is_enforced() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + + vec![], ), Error::::InvalidTrancheStructure ); @@ -1437,6 +1439,8 @@ fn valid_tranche_structure_is_enforced() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + + vec![], ), Error::::InvalidTrancheStructure ); @@ -1491,6 +1495,8 @@ fn valid_tranche_structure_is_enforced() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + + vec![], ), Error::::InvalidTrancheStructure ); @@ -1542,6 +1548,8 @@ fn valid_tranche_structure_is_enforced() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + + vec![], ), Error::::InvalidTrancheStructure ); @@ -1555,9 +1563,8 @@ fn triger_challange_period_with_zero_solution() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( @@ -1587,6 +1594,7 @@ fn triger_challange_period_with_zero_solution() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); // Force min_epoch_time to 0 without using update @@ -1649,9 +1657,8 @@ fn min_challenge_time_is_respected() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( @@ -1681,6 +1688,7 @@ fn min_challenge_time_is_respected() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); // Force min_epoch_time to 0 without using update @@ -1726,8 +1734,8 @@ fn min_challenge_time_is_respected() { ] )); - // TODO: this currently is no error as we denote the times in secsonds - // and not in blocks. THis needs to be solved in a seperate PR + // TODO: this currently is no error as we denote the times in seconds + // and not in blocks. THis needs to be solved in a separate PR /* assert_noop!( PoolSystem::execute_epoch(pool_owner_origin.clone(), 0), @@ -1746,9 +1754,8 @@ fn only_zero_solution_is_accepted_max_reserve_violated() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( @@ -1778,6 +1785,7 @@ fn only_zero_solution_is_accepted_max_reserve_violated() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); // Force min_epoch_time to 0 without using update @@ -1947,9 +1955,8 @@ fn only_zero_solution_is_accepted_when_risk_buff_violated_else() { let pool_owner_origin = RuntimeOrigin::signed(pool_owner); // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_ok!(PoolSystem::create( @@ -1979,6 +1986,7 @@ fn only_zero_solution_is_accepted_when_risk_buff_violated_else() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); // Force min_epoch_time to 0 without using update @@ -2136,9 +2144,8 @@ fn only_usd_as_pool_currency_allowed() { let pool_owner = 2_u64; // Initialize pool with initial investments - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); assert_noop!( @@ -2169,6 +2176,7 @@ fn only_usd_as_pool_currency_allowed() { ], CurrencyId::Native, 200 * CURRENCY, + vec![], ), Error::::InvalidCurrency ); @@ -2201,6 +2209,7 @@ fn only_usd_as_pool_currency_allowed() { ], CurrencyId::Tranche(0, [0u8; 16]), 200 * CURRENCY, + vec![], ), Error::::InvalidCurrency ); @@ -2232,6 +2241,7 @@ fn only_usd_as_pool_currency_allowed() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); }); } @@ -2239,9 +2249,8 @@ fn only_usd_as_pool_currency_allowed() { #[test] fn creation_takes_deposit() { new_test_ext().execute_with(|| { - const SECS_PER_YEAR: u64 = 365 * 24 * 60 * 60; let senior_interest_rate = Rate::saturating_from_rational(10, 100) - / Rate::saturating_from_integer(SECS_PER_YEAR) + / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); // Pool creation one: @@ -2276,6 +2285,7 @@ fn creation_takes_deposit() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); let pool = crate::PoolDeposit::::get(0).unwrap(); assert_eq!(pool.depositor, pool_owner); @@ -2313,6 +2323,7 @@ fn creation_takes_deposit() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); let pool = crate::PoolDeposit::::get(1).unwrap(); assert_eq!(pool.depositor, pool_owner); @@ -2352,6 +2363,7 @@ fn creation_takes_deposit() { ], AUSD_CURRENCY_ID, 200 * CURRENCY, + vec![], )); let pool = crate::PoolDeposit::::get(2).unwrap(); @@ -2399,6 +2411,7 @@ fn create_tranche_token_metadata() { ], AUSD_CURRENCY_ID, 10_000 * CURRENCY, + vec![], )); let pool = Pool::::get(3).unwrap(); @@ -2661,6 +2674,640 @@ mod changes { } } +mod pool_fees { + use cfg_types::pools::{PoolFeeAmount, PoolFeeType}; + use frame_support::traits::fungibles::Inspect; + use pallet_pool_fees::PoolFeeInfoOf; + + use super::*; + use crate::mock::default_pool_fees; + + const POOL_OWNER: MockAccountId = 2; + const INVESTMENT_AMOUNT: Balance = DEFAULT_POOL_MAX_RESERVE / 10; + const NAV_AMOUNT: Balance = INVESTMENT_AMOUNT / 2 + 2_345_000; + const FEE_AMOUNT_FIXED: Balance = NAV_AMOUNT / 10; + const NAV_REDUCTION_REDEMPTION: Balance = NAV_AMOUNT / 100 * 100; + + fn default_fulfillment_rate() -> Perquintill { + Perquintill::from_percent(25) + } + + fn reserve_adjustment_amount() -> Balance { + default_fulfillment_rate() * (INVESTMENT_AMOUNT + NAV_REDUCTION_REDEMPTION) + } + + fn create_fee_pool_setup(fees: Vec<(PoolFeeBucket, pallet_pool_fees::PoolFeeInfoOf)>) { + let interest_rate = Rate::saturating_from_rational(10, 100); + let senior_interest_rate = + interest_rate / Rate::saturating_from_integer(SECONDS_PER_YEAR) + One::one(); + assert_ok!(PoolSystem::create( + POOL_OWNER, + POOL_OWNER, + DEFAULT_POOL_ID, + vec![ + TrancheInput { + tranche_type: TrancheType::Residual, + seniority: None, + metadata: TrancheMetadata { + token_name: BoundedVec::default(), + token_symbol: BoundedVec::default(), + } + }, + TrancheInput { + tranche_type: TrancheType::NonResidual { + interest_rate_per_sec: senior_interest_rate, + min_risk_buffer: Perquintill::from_percent(10), + }, + seniority: None, + metadata: TrancheMetadata { + token_name: BoundedVec::default(), + token_symbol: BoundedVec::default(), + } + } + ], + AUSD_CURRENCY_ID, + DEFAULT_POOL_MAX_RESERVE, + fees, + )); + test_nav_up(DEFAULT_POOL_ID, NAV_AMOUNT); + + // Force min_epoch_time to 0 without using update + // as this breaks the runtime-defined pool + // parameter bounds and update will not allow this. + // + // Also force initital reserve to not be empty + Pool::::try_mutate(0, |maybe_pool| -> Result<(), ()> { + maybe_pool.as_mut().unwrap().parameters.min_epoch_time = 0; + maybe_pool.as_mut().unwrap().parameters.max_nav_age = u64::MAX; + Ok(()) + }) + .unwrap(); + + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 0 + ); + } + + #[test] + fn execute_epoch_without_fees() { + new_test_ext().execute_with(|| { + // Create pool without fees + create_fee_pool_setup(vec![]); + + // Invest to prepare increment of reserve from 0 to 2 * INVESTMENT_AMOUNT and to + // be able to redeem + invest_close_and_collect( + DEFAULT_POOL_ID, + vec![ + (0, JuniorTrancheId::get(), INVESTMENT_AMOUNT), + (1, SeniorTrancheId::get(), INVESTMENT_AMOUNT), + ], + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 2 * INVESTMENT_AMOUNT + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .available, + 2 * INVESTMENT_AMOUNT + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + + // Attempt to redeem everything + assert_ok!(Investments::update_redeem_order( + RuntimeOrigin::signed(0), + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + assert_ok!(PoolSystem::submit_solution( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID, + vec![ + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: default_fulfillment_rate(), + }, + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + } + ] + )); + + // Execute epoch 1 should reduce reserve due to redemption + assert_ok!(PoolSystem::execute_epoch( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID + )); + assert!(!EpochExecution::::contains_key(DEFAULT_POOL_ID)); + + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 2 * INVESTMENT_AMOUNT - reserve_adjustment_amount(), + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .available, + 2 * INVESTMENT_AMOUNT - reserve_adjustment_amount(), + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + + // Closing epoch 2 should not change anything but reserve.available + next_block(); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 2 * INVESTMENT_AMOUNT - reserve_adjustment_amount(), + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .available, + 0, + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + (0, Timestamp::now() / 1000) + ); + }); + } + #[test] + fn execute_epoch_with_fees() { + new_test_ext().execute_with(|| { + let fees_account = PoolFees::account_id(); + let fees: Vec<(PoolFeeBucket, pallet_pool_fees::PoolFeeInfoOf)> = + default_pool_fees() + .into_iter() + .map(|fee| (PoolFeeBucket::Top, fee)) + .collect(); + + // Create pool with fees + create_fee_pool_setup(fees); + + // Invest and collect to be able to redeem + invest_close_and_collect( + DEFAULT_POOL_ID, + vec![ + (0, JuniorTrancheId::get(), INVESTMENT_AMOUNT), + (1, SeniorTrancheId::get(), INVESTMENT_AMOUNT), + ], + ); + // Fees should be zero because no time has elapsed yet + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![(0, 0, None), (0, 0, Some(0))], + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 2 * INVESTMENT_AMOUNT + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .available, + 2 * INVESTMENT_AMOUNT + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + + // Closing should update fee nav by disbursements because reserve is sufficient + assert_ok!(Investments::update_redeem_order( + RuntimeOrigin::signed(0), + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + next_block(); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + (0, Timestamp::now() / 1000) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![ + (0, FEE_AMOUNT_FIXED, None), + ( + 0, + 0, + Some( + POOL_FEE_CHARGED_AMOUNT_PER_SECOND + * 12 * Balance::from(System::block_number() - 1), + ), + ), + ], + ); + assert_eq!( + OrmlTokens::balance(AUSD_CURRENCY_ID, &fees_account), + FEE_AMOUNT_FIXED + ); + assert_eq!( + OrmlTokens::balance(AUSD_CURRENCY_ID, &DEFAULT_FEE_DESTINATION), + 0, + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .total, + 2 * INVESTMENT_AMOUNT - FEE_AMOUNT_FIXED, + ); + assert_eq!( + Pool::::get(DEFAULT_POOL_ID) + .expect("Pool exists") + .reserve + .available, + 0, + ); + + // Executing epoch should reduce FeeNav by disbursement and transfer from + // PoolFees account to destination + assert_ok!(PoolSystem::submit_solution( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID, + vec![ + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: default_fulfillment_rate(), + }, + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + } + ] + )); + assert_ok!(PoolSystem::execute_epoch( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID + )); + assert!(!EpochExecution::::contains_key(DEFAULT_POOL_ID)); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (NAV_AMOUNT, 0) + ); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + (0, Timestamp::now() / 1000) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![ + (0, 0, None), + ( + 0, + 0, + Some( + POOL_FEE_CHARGED_AMOUNT_PER_SECOND + * 12 * Balance::from(System::block_number() - 1), + ), + ), + ], + ); + + // Extra: Update AssetsUnderManagementNAV to ensure PoolFeesNAV uses one + // from last epoch + let new_nav_amount = NAV_AMOUNT * 4; + next_block(); + test_nav_up(DEFAULT_POOL_ID, new_nav_amount - NAV_AMOUNT); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (new_nav_amount, 0) + ); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + (0, Timestamp::now() / 1000) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![ + (0, FEE_AMOUNT_FIXED, None), + ( + 0, + 0, + Some( + POOL_FEE_CHARGED_AMOUNT_PER_SECOND + * 12 * Balance::from(System::block_number() - 1), + ), + ), + ], + ); + }); + } + + #[test] + fn execute_epoch_with_overcharged_fees() { + new_test_ext().execute_with(|| { + let charged_amount = 2 * NAV_AMOUNT; + let fees: Vec<(PoolFeeBucket, pallet_pool_fees::PoolFeeInfoOf)> = + default_pool_fees() + .into_iter() + .map(|fee| (PoolFeeBucket::Top, fee)) + .collect(); + + // Create pool with fees + create_fee_pool_setup(fees); + + // Overcharge fee to increase pending amount and thus PoolFeesNAV + assert_ok!(PoolFees::charge_fee( + RuntimeOrigin::signed(DEFAULT_FEE_DESTINATION), + 2, + charged_amount, + )); + + // NAV = 0 + AUM - PoolFeesNAV = -AUM + assert_noop!( + PoolSystem::close_epoch(RuntimeOrigin::signed(POOL_OWNER), 0), + Error::::NegativeBalanceSheet + ); + + // Increase NAV by NAV_AMOUNT to reach equilibrium (AUM == PoolFeesNAV) + test_nav_up(DEFAULT_POOL_ID, NAV_AMOUNT); + + // Invest and collect to be able to redeem + invest_close_and_collect( + DEFAULT_POOL_ID, + vec![ + (0, JuniorTrancheId::get(), INVESTMENT_AMOUNT), + (1, SeniorTrancheId::get(), INVESTMENT_AMOUNT), + ], + ); + + // Redeem all junior and senior tranche tokens to require manual epoch execution + assert_ok!(Investments::update_redeem_order( + RuntimeOrigin::signed(0), + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + assert_ok!(Investments::update_redeem_order( + RuntimeOrigin::signed(1), + TrancheCurrency::generate(DEFAULT_POOL_ID, SeniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + + // Closing should update fee nav + next_block(); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + let fee_amount_from_charge = + POOL_FEE_CHARGED_AMOUNT_PER_SECOND * 12 * Balance::from(System::block_number() - 1); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + ( + charged_amount - fee_amount_from_charge, + Timestamp::now() / 1000 + ) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![ + (0, 2 * FEE_AMOUNT_FIXED, None), + ( + charged_amount - fee_amount_from_charge, + fee_amount_from_charge, + Some(0), + ), + ], + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (2 * NAV_AMOUNT, 0) + ); + + // Executin should reduce fee_nav by disbursement and transfer + assert_ok!(PoolSystem::submit_solution( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID, + vec![ + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: default_fulfillment_rate(), + }, + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + } + ] + )); + assert_ok!(PoolSystem::execute_epoch( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID + )); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + ( + charged_amount - fee_amount_from_charge, + Timestamp::now() / 1000 + ) + ); + assert_eq!( + ::AssetsUnderManagementNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists"), + (2 * NAV_AMOUNT, 0) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + default_pool_fees(), + vec![ + (0, 0, None), + (charged_amount - fee_amount_from_charge, 0, Some(0)), + ], + ); + assert_eq!( + OrmlTokens::balance(AUSD_CURRENCY_ID, &DEFAULT_FEE_DESTINATION), + 2 * FEE_AMOUNT_FIXED + fee_amount_from_charge, + ); + }); + } + + #[test] + fn execute_epoch_with_fees_insufficient_reserve() { + new_test_ext().execute_with(|| { + let base_fee = INVESTMENT_AMOUNT * 2; + let fee_aps = base_fee / 12; + let fee_disbursement = DEFAULT_POOL_MAX_RESERVE / 10; + let fee_nav = fee_aps * 12 - fee_disbursement; + + let fees = vec![PoolFeeInfoOf:: { + destination: DEFAULT_FEE_DESTINATION, + editor: DEFAULT_FEE_EDITOR, + fee_type: PoolFeeType::Fixed { + // Charge entire reserve in one second to block redemption settlement + limit: PoolFeeAmount::AmountPerSecond(fee_aps), + }, + }]; + + // Create pool with single fee which consumes entire reserve + create_fee_pool_setup(vec![(PoolFeeBucket::Top, fees[0].clone())]); + test_nav_up(DEFAULT_POOL_ID, DEFAULT_POOL_MAX_RESERVE - NAV_AMOUNT); + + // Invest and collect to be able to redeem + invest_close_and_collect( + DEFAULT_POOL_ID, + vec![(0, JuniorTrancheId::get(), INVESTMENT_AMOUNT)], + ); + + // Reinvest to check for fulfillment later + assert_ok!(Investments::update_invest_order( + RuntimeOrigin::signed(0), + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + assert_ok!(Investments::update_redeem_order( + RuntimeOrigin::signed(0), + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()), + INVESTMENT_AMOUNT + )); + + // Closing should update fee nav + next_block(); + assert_ok!(PoolSystem::close_epoch( + RuntimeOrigin::signed(POOL_OWNER), + 0 + )); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID).expect("Pool exists"), + (fee_nav, Timestamp::now() / 1000) + ); + assert_pending_fees( + DEFAULT_POOL_ID, + fees.clone(), + vec![(fee_nav, fee_disbursement, None)], + ); + + // Should not be able to invest and redeem everything because reserve is drained + // by fees + assert_noop!( + PoolSystem::submit_solution( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID, + vec![ + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + }, + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + } + ] + ), + Error::::InsufficientCurrency + ); + assert_ok!(PoolSystem::submit_solution( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID, + vec![ + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::from_percent(10), + }, + TrancheSolution { + invest_fulfillment: Perquintill::one(), + redeem_fulfillment: Perquintill::one(), + } + ] + )); + assert_ok!(PoolSystem::execute_epoch( + RuntimeOrigin::signed(POOL_OWNER), + DEFAULT_POOL_ID + )); + assert_pending_fees(DEFAULT_POOL_ID, fees.clone(), vec![(fee_nav, 0, None)]); + assert_eq!( + ::PoolFeesNAV::nav(DEFAULT_POOL_ID) + .expect("Pool exists") + .0, + fee_nav + ); + assert_eq!( + pallet_investments::InvestOrders::::get( + 0, + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()) + ) + .expect("InvestOrders should not be fulfilled due to reserve drain from pool fees") + .amount(), + INVESTMENT_AMOUNT + ); + assert_eq!( + pallet_investments::RedeemOrders::::get( + 0, + TrancheCurrency::generate(DEFAULT_POOL_ID, JuniorTrancheId::get()) + ) + .expect("RedeemOrder should not be fulfilled due to reserve drain from pool fees") + .amount(), + INVESTMENT_AMOUNT + ); + }); + } +} + #[test] #[cfg(feature = "runtime-benchmarks")] fn benchmark_pool() { diff --git a/pallets/pool-system/src/weights.rs b/pallets/pool-system/src/weights.rs index d84b8df579..c3bd3e9bf6 100644 --- a/pallets/pool-system/src/weights.rs +++ b/pallets/pool-system/src/weights.rs @@ -14,36 +14,36 @@ use frame_support::weights::Weight; pub trait WeightInfo { - fn set_max_reserve() -> Weight; - fn close_epoch_no_orders(n: u32) -> Weight; - fn close_epoch_no_execution(n: u32) -> Weight; - fn close_epoch_execute(n: u32) -> Weight; - fn submit_solution(n: u32) -> Weight; - fn execute_epoch(n: u32) -> Weight; + fn set_max_reserve(m: u32) -> Weight; + fn close_epoch_no_orders(n: u32, m: u32) -> Weight; + fn close_epoch_no_execution(n: u32, m: u32) -> Weight; + fn close_epoch_execute(n: u32, m: u32) -> Weight; + fn submit_solution(n: u32, m: u32) -> Weight; + fn execute_epoch(n: u32, m: u32) -> Weight; } impl WeightInfo for () { - fn set_max_reserve() -> Weight { + fn set_max_reserve(_: u32) -> Weight { Weight::zero() } - fn close_epoch_no_orders(_: u32) -> Weight { + fn close_epoch_no_orders(_: u32, _: u32) -> Weight { Weight::zero() } - fn close_epoch_no_execution(_: u32) -> Weight { + fn close_epoch_no_execution(_: u32, _: u32) -> Weight { Weight::zero() } - fn close_epoch_execute(_: u32) -> Weight { + fn close_epoch_execute(_: u32, _: u32) -> Weight { Weight::zero() } - fn submit_solution(_: u32) -> Weight { + fn submit_solution(_: u32, _: u32) -> Weight { Weight::zero() } - fn execute_epoch(_: u32) -> Weight { + fn execute_epoch(_: u32, _: u32) -> Weight { Weight::zero() } } diff --git a/runtime/altair/Cargo.toml b/runtime/altair/Cargo.toml index 2189bd30f0..0ab4b0191c 100644 --- a/runtime/altair/Cargo.toml +++ b/runtime/altair/Cargo.toml @@ -119,6 +119,7 @@ pallet-oracle-collection = { workspace = true } pallet-oracle-feed = { workspace = true } pallet-order-book = { workspace = true } pallet-permissions = { workspace = true } +pallet-pool-fees = { workspace = true } pallet-pool-registry = { workspace = true } pallet-pool-system = { workspace = true } pallet-preimage = { workspace = true } @@ -248,6 +249,7 @@ std = [ "pallet-oracle-feed/std", "pallet-order-book/std", "pallet-permissions/std", + "pallet-pool-fees/std", "pallet-pool-registry/std", "pallet-pool-system/std", "pallet-preimage/std", @@ -336,6 +338,7 @@ runtime-benchmarks = [ "pallet-oracle-feed/runtime-benchmarks", "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", @@ -424,6 +427,7 @@ try-runtime = [ "pallet-oracle-feed/try-runtime", "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-pool-registry/try-runtime", "pallet-pool-system/try-runtime", "pallet-preimage/try-runtime", diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index 36dce4e9ce..d2296bfed7 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -1472,6 +1472,7 @@ parameter_types! { impl pallet_pool_system::Config for Runtime { type AssetRegistry = OrmlAssetRegistry; + type AssetsUnderManagementNAV = Loans; type Balance = Balance; type BalanceRatio = Quantity; type ChallengeTime = ChallengeTime; @@ -1488,13 +1489,15 @@ impl pallet_pool_system::Config for Runtime { type MinEpochTimeLowerBound = MinEpochTimeLowerBound; type MinEpochTimeUpperBound = MinEpochTimeUpperBound; type MinUpdateDelay = MinUpdateDelay; - type NAV = Loans; + type OnEpochTransition = PoolFees; type PalletId = PoolPalletId; type PalletIndex = PoolPalletIndex; type Permission = Permissions; type PoolCreateOrigin = EnsureRoot; type PoolCurrency = PoolCurrency; type PoolDeposit = PoolDeposit; + type PoolFees = PoolFees; + type PoolFeesNAV = PoolFees; type PoolId = PoolId; type Rate = Rate; type RuntimeChange = runtime_common::changes::RuntimeChange; @@ -1528,6 +1531,30 @@ impl pallet_pool_registry::Config for Runtime { type WeightInfo = weights::pallet_pool_registry::WeightInfo; } +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = MAX_POOL_FEES_PER_BUCKET; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; + pub const MaxFeesPerPool: u32 = MAX_FEES_PER_POOL; +} + +impl pallet_pool_fees::Config for Runtime { + type Balance = Balance; + type ChangeGuard = PoolSystem; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = PoolAdminCheck; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = PoolSystem; + type Rate = Rate; + type RuntimeChange = runtime_common::changes::RuntimeChange; + type RuntimeEvent = RuntimeEvent; + type Time = Timestamp; + type Tokens = Tokens; +} + pub struct PoolCurrency; impl Contains for PoolCurrency { fn contains(id: &CurrencyId) -> bool { @@ -1793,6 +1820,7 @@ construct_runtime!( TransferAllowList: pallet_transfer_allowlist::{Pallet, Call, Storage, Event} = 115, OraclePriceFeed: pallet_oracle_feed::{Pallet, Call, Storage, Event} = 116, OraclePriceCollection: pallet_oracle_collection::{Pallet, Call, Storage, Event} = 117, + PoolFees: pallet_pool_fees::{Pallet, Call, Storage, Event} = 118, // XCM XcmpQueue: cumulus_pallet_xcmp_queue::{Pallet, Call, Storage, Event} = 120, @@ -2467,6 +2495,7 @@ impl_runtime_apis! { list_benchmark!(list, extra, pallet_transfer_allowlist, TransferAllowList); list_benchmark!(list, extra, pallet_oracle_feed, OraclePriceFeed); list_benchmark!(list, extra, pallet_oracle_collection, OraclePriceCollection); + list_benchmark!(list, extra, pallet_pool_fees, PoolFees); let storage_info = AllPalletsWithSystem::storage_info(); @@ -2546,6 +2575,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_transfer_allowlist, TransferAllowList); add_benchmark!(params, batches, pallet_oracle_feed, OraclePriceFeed); add_benchmark!(params, batches, pallet_oracle_collection, OraclePriceCollection); + add_benchmark!(params, batches, pallet_pool_fees, PoolFees); if batches.is_empty() { return Err("Benchmark not found for this pallet.".into()) } Ok(batches) diff --git a/runtime/altair/src/migrations.rs b/runtime/altair/src/migrations.rs index a159a597a3..3b522fc7ab 100644 --- a/runtime/altair/src/migrations.rs +++ b/runtime/altair/src/migrations.rs @@ -51,6 +51,8 @@ pub type UpgradeAltair1034 = ( xcm_v2_to_v3::SetSafeXcmVersion, // Sets account codes for all precompiles runtime_common::migrations::precompile_account_codes::Migration, + // Migrates EpochExecution V1 to V2 + runtime_common::migrations::epoch_execution::Migration, ); mod asset_registry { diff --git a/runtime/altair/src/weights/pallet_pool_system.rs b/runtime/altair/src/weights/pallet_pool_system.rs index 53cdca3bdc..ec6b2bab31 100644 --- a/runtime/altair/src/weights/pallet_pool_system.rs +++ b/runtime/altair/src/weights/pallet_pool_system.rs @@ -36,7 +36,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Proof: Permissions Permission (max_values: None, max_size: Some(228), added: 2703, mode: MaxEncodedLen) /// Storage: PoolSystem Pool (r:1 w:1) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) - fn set_max_reserve() -> Weight { + fn set_max_reserve(_m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `625` // Estimated: `4278` @@ -75,7 +75,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_orders(n: u32, ) -> Weight { + fn close_epoch_no_orders(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `809 + n * (133 ±0)` // Estimated: `10715 + n * (2604 ±0)` @@ -113,7 +113,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments RedeemOrderId (r:5 w:5) /// Proof: Investments RedeemOrderId (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_execution(n: u32, ) -> Weight { + fn close_epoch_no_execution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `975 + n * (133 ±0)` // Estimated: `10715 + n * (2531 ±0)` @@ -161,7 +161,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_execute(n: u32, ) -> Weight { + fn close_epoch_execute(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1782 + n * (167 ±0)` // Estimated: `10715 + n * (2604 ±0)` @@ -181,7 +181,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: PoolSystem Pool (r:1 w:0) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn submit_solution(n: u32, ) -> Weight { + fn submit_solution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `429 + n * (249 ±0)` // Estimated: `4278` @@ -224,7 +224,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn execute_epoch(n: u32, ) -> Weight { + fn execute_epoch(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1695 + n * (639 ±0)` // Estimated: `7634 + n * (2604 ±0)` diff --git a/runtime/centrifuge/Cargo.toml b/runtime/centrifuge/Cargo.toml index 190c4c8397..314a3d8a47 100644 --- a/runtime/centrifuge/Cargo.toml +++ b/runtime/centrifuge/Cargo.toml @@ -119,6 +119,7 @@ pallet-oracle-collection = { workspace = true } pallet-oracle-feed = { workspace = true } pallet-order-book = { workspace = true } pallet-permissions = { workspace = true } +pallet-pool-fees = { workspace = true } pallet-pool-registry = { workspace = true } pallet-pool-system = { workspace = true } pallet-preimage = { workspace = true } @@ -249,6 +250,7 @@ std = [ "pallet-oracle-feed/std", "pallet-order-book/std", "pallet-permissions/std", + "pallet-pool-fees/std", "pallet-pool-registry/std", "pallet-pool-system/std", "pallet-preimage/std", @@ -337,6 +339,7 @@ runtime-benchmarks = [ "pallet-oracle-feed/runtime-benchmarks", "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", @@ -426,6 +429,7 @@ try-runtime = [ "pallet-oracle-feed/try-runtime", "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-pool-registry/try-runtime", "pallet-pool-system/try-runtime", "pallet-preimage/try-runtime", diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index e81951aa8a..072a4de3f4 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -1501,6 +1501,7 @@ impl pallet_pool_registry::Config for Runtime { impl pallet_pool_system::Config for Runtime { type AssetRegistry = OrmlAssetRegistry; + type AssetsUnderManagementNAV = Loans; type Balance = Balance; type BalanceRatio = Quantity; type ChallengeTime = ChallengeTime; @@ -1517,13 +1518,15 @@ impl pallet_pool_system::Config for Runtime { type MinEpochTimeLowerBound = MinEpochTimeLowerBound; type MinEpochTimeUpperBound = MinEpochTimeUpperBound; type MinUpdateDelay = MinUpdateDelay; - type NAV = Loans; + type OnEpochTransition = PoolFees; type PalletId = PoolPalletId; type PalletIndex = PoolPalletIndex; type Permission = Permissions; type PoolCreateOrigin = EnsureRoot; type PoolCurrency = PoolCurrency; type PoolDeposit = PoolDeposit; + type PoolFees = PoolFees; + type PoolFeesNAV = PoolFees; type PoolId = PoolId; type Rate = Rate; type RuntimeChange = runtime_common::changes::RuntimeChange; @@ -1593,6 +1596,30 @@ impl } } +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = MAX_POOL_FEES_PER_BUCKET; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; + pub const MaxFeesPerPool: u32 = MAX_FEES_PER_POOL; +} + +impl pallet_pool_fees::Config for Runtime { + type Balance = Balance; + type ChangeGuard = PoolSystem; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = PoolAdminCheck; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = PoolSystem; + type Rate = Rate; + type RuntimeChange = runtime_common::changes::RuntimeChange; + type RuntimeEvent = RuntimeEvent; + type Time = Timestamp; + type Tokens = Tokens; +} + impl pallet_permissions::Config for Runtime { type AdminOrigin = EnsureRootOr; type Editors = Editors; @@ -1913,6 +1940,7 @@ construct_runtime!( OraclePriceFeed: pallet_oracle_feed::{Pallet, Call, Storage, Event} = 111, OraclePriceCollection: pallet_oracle_collection::{Pallet, Call, Storage, Event} = 112, Remarks: pallet_remarks::{Pallet, Call, Event} = 113, + PoolFees: pallet_pool_fees::{Pallet, Call, Storage, Event} = 114, // XCM XcmpQueue: cumulus_pallet_xcmp_queue::{Pallet, Call, Storage, Event} = 120, @@ -2528,6 +2556,7 @@ impl_runtime_apis! { list_benchmark!(list, extra, pallet_oracle_feed, OraclePriceFeed); list_benchmark!(list, extra, pallet_oracle_collection, OraclePriceCollection); list_benchmark!(list, extra, pallet_remarks, Remarks); + list_benchmark!(list, extra, pallet_pool_fees, PoolFees); let storage_info = AllPalletsWithSystem::storage_info(); @@ -2606,6 +2635,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_oracle_feed, OraclePriceFeed); add_benchmark!(params, batches, pallet_oracle_collection, OraclePriceCollection); add_benchmark!(params, batches, pallet_remarks, Remarks); + add_benchmark!(params, batches, pallet_pool_fees, PoolFees); if batches.is_empty() { return Err("Benchmark not found for this pallet.".into()) } Ok(batches) diff --git a/runtime/centrifuge/src/migrations.rs b/runtime/centrifuge/src/migrations.rs index 248cd5ca53..c6a7cd8949 100644 --- a/runtime/centrifuge/src/migrations.rs +++ b/runtime/centrifuge/src/migrations.rs @@ -10,7 +10,10 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -pub type UpgradeCentrifuge1025 = burn_unburned::Migration; +pub type UpgradeCentrifuge1025 = ( + burn_unburned::Migration, + runtime_common::migrations::epoch_execution::Migration, +); // Copyright 2021 Centrifuge Foundation (centrifuge.io). // diff --git a/runtime/centrifuge/src/weights/pallet_pool_system.rs b/runtime/centrifuge/src/weights/pallet_pool_system.rs index f7c9d27e9b..26a599384c 100644 --- a/runtime/centrifuge/src/weights/pallet_pool_system.rs +++ b/runtime/centrifuge/src/weights/pallet_pool_system.rs @@ -36,7 +36,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Proof: Permissions Permission (max_values: None, max_size: Some(228), added: 2703, mode: MaxEncodedLen) /// Storage: PoolSystem Pool (r:1 w:1) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) - fn set_max_reserve() -> Weight { + fn set_max_reserve(_m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `625` // Estimated: `4278` @@ -75,7 +75,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_orders(n: u32, ) -> Weight { + fn close_epoch_no_orders(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `875 + n * (133 ±0)` // Estimated: `27515 + n * (2604 ±0)` @@ -113,7 +113,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments RedeemOrderId (r:5 w:5) /// Proof: Investments RedeemOrderId (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_execution(n: u32, ) -> Weight { + fn close_epoch_no_execution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1041 + n * (133 ±0)` // Estimated: `27515 + n * (2531 ±0)` @@ -161,7 +161,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_execute(n: u32, ) -> Weight { + fn close_epoch_execute(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1848 + n * (167 ±0)` // Estimated: `27515 + n * (2604 ±0)` @@ -181,7 +181,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: PoolSystem Pool (r:1 w:0) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn submit_solution(n: u32, ) -> Weight { + fn submit_solution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `429 + n * (249 ±0)` // Estimated: `4278` @@ -224,7 +224,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn execute_epoch(n: u32, ) -> Weight { + fn execute_epoch(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1728 + n * (639 ±0)` // Estimated: `7667 + n * (2604 ±0)` diff --git a/runtime/common/Cargo.toml b/runtime/common/Cargo.toml index 3e5e4ba914..c9836e4067 100644 --- a/runtime/common/Cargo.toml +++ b/runtime/common/Cargo.toml @@ -97,6 +97,7 @@ pallet-oracle-collection = { workspace = true } pallet-oracle-feed = { workspace = true } pallet-order-book = { workspace = true } pallet-permissions = { workspace = true } +pallet-pool-fees = { workspace = true } pallet-pool-registry = { workspace = true } pallet-pool-system = { workspace = true } pallet-preimage = { workspace = true } @@ -234,6 +235,7 @@ std = [ "pallet-oracle-feed/std", "pallet-order-book/std", "pallet-permissions/std", + "pallet-pool-fees/std", "pallet-pool-registry/std", "pallet-pool-system/std", "pallet-preimage/std", @@ -314,6 +316,7 @@ runtime-benchmarks = [ "pallet-oracle-feed/runtime-benchmarks", "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", @@ -391,6 +394,7 @@ try-runtime = [ "pallet-oracle-feed/try-runtime", "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-pool-registry/try-runtime", "pallet-pool-system/try-runtime", "pallet-preimage/try-runtime", diff --git a/runtime/common/src/changes.rs b/runtime/common/src/changes.rs index 5858b3e220..0cfc6a22dd 100644 --- a/runtime/common/src/changes.rs +++ b/runtime/common/src/changes.rs @@ -1,6 +1,7 @@ use frame_support::RuntimeDebug; use pallet_loans::entities::changes::Change as LoansChange; use pallet_oracle_collection::types::Change as OracleCollectionChange; +use pallet_pool_fees::types::Change as PoolFeesChange; use pallet_pool_system::pool_types::changes::{PoolChangeProposal, Requirement}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; @@ -8,14 +9,21 @@ use sp_runtime::DispatchError; use sp_std::{marker::PhantomData, vec::Vec}; /// Auxiliar type to carry all pallets bounds used by RuntimeChange -pub trait Changeable: pallet_loans::Config + pallet_oracle_collection::Config {} -impl Changeable for T {} +pub trait Changeable: + pallet_loans::Config + pallet_oracle_collection::Config + pallet_pool_fees::Config +{ +} +impl + Changeable for T +{ +} /// A change done in the runtime, shared between pallets #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub enum RuntimeChange { Loans(LoansChange), OracleCollection(OracleCollectionChange), + PoolFee(PoolFeesChange), _Unreachable(PhantomData), } @@ -58,6 +66,9 @@ impl RuntimeChange { RuntimeChange::OracleCollection(change) => match change { OracleCollectionChange::CollectionInfo(_) => vec![], }, + RuntimeChange::PoolFee(pool_fees_change) => match pool_fees_change { + PoolFeesChange::AppendFee(_, _) => vec![week], + }, RuntimeChange::_Unreachable(_) => vec![], } } @@ -113,3 +124,4 @@ macro_rules! runtime_change_support { // Add the variants you want to support for RuntimeChange runtime_change_support!(LoansChange, Loans); runtime_change_support!(OracleCollectionChange, OracleCollection); +runtime_change_support!(PoolFeesChange, PoolFee); diff --git a/runtime/common/src/fees.rs b/runtime/common/src/fees.rs index f09d98cb1a..d03ce93d4d 100644 --- a/runtime/common/src/fees.rs +++ b/runtime/common/src/fees.rs @@ -140,6 +140,7 @@ impl< #[cfg(test)] mod test { use cfg_primitives::{AccountId, TREASURY_FEE_RATIO}; + use cfg_types::ids::TREASURY_PALLET_ID; use frame_support::{ parameter_types, traits::{Currency, FindAuthor}, @@ -222,7 +223,7 @@ mod test { } parameter_types! { - pub const TreasuryPalletId: PalletId = PalletId(*b"py/trsry"); + pub const TreasuryPalletId: PalletId = TREASURY_PALLET_ID; pub const MaxApprovals: u32 = 100; } diff --git a/runtime/common/src/migrations/epoch_execution.rs b/runtime/common/src/migrations/epoch_execution.rs new file mode 100644 index 0000000000..68c6e03086 --- /dev/null +++ b/runtime/common/src/migrations/epoch_execution.rs @@ -0,0 +1,142 @@ +// Copyright 2023 Centrifuge Foundation (centrifuge.io). +// +// This file is part of the Centrifuge chain project. +// Centrifuge 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 (see http://www.gnu.org/licenses). +// Centrifuge 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. + +use frame_support::traits::{Get, GetStorageVersion, OnRuntimeUpgrade, StorageVersion}; +use pallet_order_book::weights::Weight; +use pallet_pool_system::{Config, EpochExecution, EpochExecutionInfo, Nav, Pallet as PoolSystem}; +#[cfg(feature = "try-runtime")] +use parity_scale_codec::{Decode, Encode}; +use sp_runtime::traits::Zero; + +const LOG_PREFIX: &str = "EpochExecutionMigration: "; + +pub(crate) mod v1 { + use frame_support::{ + dispatch::{Decode, Encode, MaxEncodedLen, TypeInfo}, + pallet_prelude::Get, + RuntimeDebug, + }; + use pallet_pool_system::{tranches::EpochExecutionTranches, Config, EpochSolution}; + + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] + pub(crate) struct EpochExecutionInfo< + Balance, + BalanceRatio, + EpochId, + Weight, + BlockNumber, + TrancheCurrency, + MaxTranches, + > where + MaxTranches: Get, + { + pub epoch: EpochId, + pub nav: Balance, + pub reserve: Balance, + pub max_reserve: Balance, + pub tranches: + EpochExecutionTranches, + pub best_submission: Option>, + pub challenge_period_end: Option, + } + + pub(crate) type EpochExecutionInfoOf = EpochExecutionInfo< + ::Balance, + ::BalanceRatio, + ::EpochId, + ::TrancheWeight, + ::BlockNumber, + ::TrancheCurrency, + ::MaxTranches, + >; + + #[cfg(feature = "try-runtime")] + #[frame_support::storage_alias] + pub(crate) type EpochExecution = StorageMap< + pallet_pool_system::Pallet, + frame_support::Blake2_128Concat, + ::PoolId, + EpochExecutionInfoOf, + >; +} + +pub struct Migration +where + T: Config + frame_system::Config, +{ + _phantom: sp_std::marker::PhantomData, +} + +impl OnRuntimeUpgrade for Migration +where + T: Config + frame_system::Config, +{ + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + frame_support::ensure!( + PoolSystem::::on_chain_storage_version() == 1, + "Can only upgrade from PoolSystem version 1" + ); + let count = v1::EpochExecution::::iter().count() as u32; + log::info!( + "{LOG_PREFIX} EpochExecution count pre migration is {}", + count + ); + + Ok(count.encode()) + } + + fn on_runtime_upgrade() -> Weight { + let mut weight = T::DbWeight::get().reads(1); + if PoolSystem::::on_chain_storage_version() != 1 { + log::info!( + "{LOG_PREFIX} Skipping on_runtime_upgrade: executed on wrong storage version. Expected version 1" + ); + return weight; + } + + EpochExecution::::translate::, _>(|_, old| { + weight.saturating_accrue(T::DbWeight::get().reads_writes(1, 1)); + Some(EpochExecutionInfo { + epoch: old.epoch, + nav: Nav::<::Balance>::new(old.nav, ::Balance::zero()), + tranches: old.tranches, + best_submission: old.best_submission, + challenge_period_end: old.challenge_period_end, + }) + }); + + StorageVersion::new(2).put::>(); + + weight + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: sp_std::vec::Vec) -> Result<(), sp_runtime::TryRuntimeError> { + frame_support::ensure!( + PoolSystem::::on_chain_storage_version() + == PoolSystem::::current_storage_version(), + "EpochExecutionMigration: StorageVersion of PoolSystem is not 2" + ); + + let old_count: u32 = Decode::decode(&mut &state[..]) + .expect("EpochExecutionMigration: pre_upgrade provides a valid state; qed"); + let new_count = EpochExecution::::iter().count() as u32; + log::info!("{LOG_PREFIX} EpochExecutionV2 count post migration is {new_count}",); + frame_support::ensure!( + old_count == new_count, + "EpochExecutionMigration: Mismatch in pre and post counters, must migrate all EpochExecution values!" + ); + + Ok(()) + } +} diff --git a/runtime/common/src/migrations/mod.rs b/runtime/common/src/migrations/mod.rs index 0f1bf252d4..4cf7e2373e 100644 --- a/runtime/common/src/migrations/mod.rs +++ b/runtime/common/src/migrations/mod.rs @@ -13,6 +13,7 @@ //! Centrifuge Runtime-Common Migrations pub mod asset_registry_xcmv3; +pub mod epoch_execution; pub mod nuke; pub mod orml_tokens; pub mod precompile_account_codes; diff --git a/runtime/development/Cargo.toml b/runtime/development/Cargo.toml index 3485a414ab..610ee4177c 100644 --- a/runtime/development/Cargo.toml +++ b/runtime/development/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "development-runtime" -version = "0.10.37" +version = "0.10.38" build = "build.rs" authors.workspace = true edition.workspace = true @@ -118,6 +118,7 @@ pallet-oracle-collection = { workspace = true } pallet-oracle-feed = { workspace = true } pallet-order-book = { workspace = true } pallet-permissions = { workspace = true } +pallet-pool-fees = { workspace = true } pallet-pool-registry = { workspace = true } pallet-pool-system = { workspace = true } pallet-preimage = { workspace = true } @@ -247,6 +248,7 @@ std = [ "pallet-oracle-feed/std", "pallet-order-book/std", "pallet-permissions/std", + "pallet-pool-fees/std", "pallet-pool-registry/std", "pallet-pool-system/std", "pallet-preimage/std", @@ -336,6 +338,7 @@ runtime-benchmarks = [ "pallet-oracle-feed/runtime-benchmarks", "pallet-order-book/runtime-benchmarks", "pallet-permissions/runtime-benchmarks", + "pallet-pool-fees/runtime-benchmarks", "pallet-pool-registry/runtime-benchmarks", "pallet-pool-system/runtime-benchmarks", "pallet-preimage/runtime-benchmarks", @@ -425,6 +428,7 @@ try-runtime = [ "pallet-oracle-feed/try-runtime", "pallet-order-book/try-runtime", "pallet-permissions/try-runtime", + "pallet-pool-fees/try-runtime", "pallet-pool-registry/try-runtime", "pallet-pool-system/try-runtime", "pallet-preimage/try-runtime", diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 79875f9f9f..31e447063f 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -142,7 +142,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: create_runtime_str!("centrifuge-devel"), impl_name: create_runtime_str!("centrifuge-devel"), authoring_version: 1, - spec_version: 1037, + spec_version: 1038, impl_version: 1, #[cfg(not(feature = "disable-runtime-api"))] apis: RUNTIME_API_VERSIONS, @@ -1060,6 +1060,7 @@ parameter_types! { impl pallet_pool_system::Config for Runtime { type AssetRegistry = OrmlAssetRegistry; + type AssetsUnderManagementNAV = Loans; type Balance = Balance; type BalanceRatio = Quantity; type ChallengeTime = ChallengeTime; @@ -1076,13 +1077,15 @@ impl pallet_pool_system::Config for Runtime { type MinEpochTimeLowerBound = MinEpochTimeLowerBound; type MinEpochTimeUpperBound = MinEpochTimeUpperBound; type MinUpdateDelay = MinUpdateDelay; - type NAV = Loans; + type OnEpochTransition = PoolFees; type PalletId = PoolPalletId; type PalletIndex = PoolPalletIndex; type Permission = Permissions; type PoolCreateOrigin = EnsureSigned; type PoolCurrency = PoolCurrency; type PoolDeposit = PoolDeposit; + type PoolFees = PoolFees; + type PoolFeesNAV = PoolFees; type PoolId = PoolId; type Rate = Rate; type RuntimeChange = runtime_common::changes::RuntimeChange; @@ -1116,6 +1119,30 @@ impl pallet_pool_registry::Config for Runtime { type WeightInfo = weights::pallet_pool_registry::WeightInfo; } +parameter_types! { + pub const MaxPoolFeesPerBucket: u32 = MAX_POOL_FEES_PER_BUCKET; + pub const PoolFeesPalletId: PalletId = cfg_types::ids::POOL_FEES_PALLET_ID; + pub const MaxFeesPerPool: u32 = MAX_FEES_PER_POOL; +} + +impl pallet_pool_fees::Config for Runtime { + type Balance = Balance; + type ChangeGuard = PoolSystem; + type CurrencyId = CurrencyId; + type FeeId = PoolFeeId; + type IsPoolAdmin = PoolAdminCheck; + type MaxFeesPerPool = MaxFeesPerPool; + type MaxPoolFeesPerBucket = MaxPoolFeesPerBucket; + type PalletId = PoolFeesPalletId; + type PoolId = PoolId; + type PoolReserve = PoolSystem; + type Rate = Rate; + type RuntimeChange = runtime_common::changes::RuntimeChange; + type RuntimeEvent = RuntimeEvent; + type Time = Timestamp; + type Tokens = Tokens; +} + pub struct PoolCurrency; impl Contains for PoolCurrency { fn contains(id: &CurrencyId) -> bool { @@ -1873,7 +1900,7 @@ construct_runtime!( Uniques: pallet_uniques::{Pallet, Call, Storage, Event} = 70, Preimage: pallet_preimage::{Pallet, Call, Storage, Event} = 72, - // our pallets + // our pallets part 1 Fees: pallet_fees::{Pallet, Call, Storage, Config, Event} = 90, Anchor: pallet_anchors::{Pallet, Call, Storage} = 91, Claims: pallet_claims::{Pallet, Call, Storage, Event} = 92, @@ -1933,6 +1960,7 @@ construct_runtime!( Sudo: pallet_sudo::{Pallet, Call, Config, Storage, Event} = 200, // our pallets part 2 + PoolFees: pallet_pool_fees::{Pallet, Call, Storage, Event} = 250, Remarks: pallet_remarks::{Pallet, Call, Event} = 251, } ); @@ -2000,7 +2028,7 @@ pub type Executive = frame_executive::Executive< frame_system::ChainContext, Runtime, AllPalletsWithSystem, - crate::migrations::UpgradeDevelopment1037, + crate::migrations::UpgradeDevelopment1038, >; impl fp_self_contained::SelfContainedCall for RuntimeCall { @@ -2625,6 +2653,7 @@ impl_runtime_apis! { add_benchmark!(params, batches, pallet_oracle_feed, OraclePriceFeed); add_benchmark!(params, batches, pallet_oracle_collection, OraclePriceCollection); add_benchmark!(params, batches, pallet_remarks, Remarks); + add_benchmark!(params, batches, pallet_pool_fees, PoolFees); if batches.is_empty() { return Err("Benchmark not found for this pallet.".into()) } Ok(batches) @@ -2685,6 +2714,7 @@ impl_runtime_apis! { list_benchmark!(list, extra, pallet_oracle_feed, OraclePriceFeed); list_benchmark!(list, extra, pallet_oracle_collection, OraclePriceCollection); list_benchmark!(list, extra, pallet_remarks, Remarks); + list_benchmark!(list, extra, pallet_pool_fees, PoolFees); let storage_info = AllPalletsWithSystem::storage_info(); diff --git a/runtime/development/src/migrations.rs b/runtime/development/src/migrations.rs index 5f3080515c..8ad8345057 100644 --- a/runtime/development/src/migrations.rs +++ b/runtime/development/src/migrations.rs @@ -10,4 +10,5 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -pub type UpgradeDevelopment1037 = (); +pub type UpgradeDevelopment1038 = + runtime_common::migrations::epoch_execution::Migration; diff --git a/runtime/development/src/weights/pallet_pool_system.rs b/runtime/development/src/weights/pallet_pool_system.rs index 7a0d5bf376..0f5db830cb 100644 --- a/runtime/development/src/weights/pallet_pool_system.rs +++ b/runtime/development/src/weights/pallet_pool_system.rs @@ -36,7 +36,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Proof: Permissions Permission (max_values: None, max_size: Some(228), added: 2703, mode: MaxEncodedLen) /// Storage: PoolSystem Pool (r:1 w:1) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) - fn set_max_reserve() -> Weight { + fn set_max_reserve(_m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `625` // Estimated: `4278` @@ -75,7 +75,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_orders(n: u32, ) -> Weight { + fn close_epoch_no_orders(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1220 + n * (175 ±0)` // Estimated: `27515 + n * (2604 ±0)` @@ -113,7 +113,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments RedeemOrderId (r:5 w:5) /// Proof: Investments RedeemOrderId (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_no_execution(n: u32, ) -> Weight { + fn close_epoch_no_execution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `1073 + n * (133 ±0)` // Estimated: `27515 + n * (2531 ±0)` @@ -161,7 +161,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn close_epoch_execute(n: u32, ) -> Weight { + fn close_epoch_execute(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `2231 + n * (197 ±0)` // Estimated: `27515 + n * (2604 ±0)` @@ -181,7 +181,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: PoolSystem Pool (r:1 w:0) /// Proof: PoolSystem Pool (max_values: None, max_size: Some(813), added: 3288, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn submit_solution(n: u32, ) -> Weight { + fn submit_solution(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `429 + n * (249 ±0)` // Estimated: `4278` @@ -224,7 +224,7 @@ impl pallet_pool_system::WeightInfo for WeightInfo { /// Storage: Investments ClearedRedeemOrders (r:0 w:5) /// Proof: Investments ClearedRedeemOrders (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) /// The range of component `n` is `[1, 5]`. - fn execute_epoch(n: u32, ) -> Weight { + fn execute_epoch(n: u32, _m: u32) -> Weight { // Proof Size summary in bytes: // Measured: `2103 + n * (671 ±0)` // Estimated: `8039 + n * (2604 ±0)` diff --git a/runtime/integration-tests/src/generic/cases/liquidity_pools.rs b/runtime/integration-tests/src/generic/cases/liquidity_pools.rs index 88927cc3ca..24fdcef0c2 100644 --- a/runtime/integration-tests/src/generic/cases/liquidity_pools.rs +++ b/runtime/integration-tests/src/generic/cases/liquidity_pools.rs @@ -350,6 +350,8 @@ mod development { ], currency_id, currency_decimals, + // No pool fees per default + vec![] )); } diff --git a/runtime/integration-tests/src/generic/utils/mod.rs b/runtime/integration-tests/src/generic/utils/mod.rs index 6204a3c5b0..a17268a194 100644 --- a/runtime/integration-tests/src/generic/utils/mod.rs +++ b/runtime/integration-tests/src/generic/utils/mod.rs @@ -130,6 +130,7 @@ pub fn create_empty_pool(admin: AccountId, pool_id: PoolId, currency Balance::MAX, None, BoundedVec::default(), + vec![], ) .unwrap(); diff --git a/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/runtime/integration-tests/src/liquidity_pools/pallet/development/tests/liquidity_pools/setup.rs @@ -0,0 +1 @@ +