From 44d0e3dfc19f6d5fdaed35615850a706908ae38d Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Thu, 18 Jul 2024 09:45:14 +0200 Subject: [PATCH 1/4] try: "same" logic, not restricting reserve during submission period --- libs/mocks/src/pools.rs | 8 +++ libs/traits/src/investments.rs | 8 +++ pallets/investments/src/lib.rs | 25 ++++----- pallets/pool-system/src/impls.rs | 16 ++++++ pallets/pool-system/src/lib.rs | 77 ++++++++++++++++++--------- pallets/pool-system/src/pool_types.rs | 17 ++++-- 6 files changed, 105 insertions(+), 46 deletions(-) diff --git a/libs/mocks/src/pools.rs b/libs/mocks/src/pools.rs index 7d0f67e0fe..552192f3d8 100644 --- a/libs/mocks/src/pools.rs +++ b/libs/mocks/src/pools.rs @@ -149,6 +149,14 @@ pub mod pallet { execute_call!((a, b)) } + fn debit(a: Self::InvestmentId, b: &T::AccountId, c: Self::Amount) -> DispatchResult { + execute_call!((a, b, c)) + } + + fn credit(a: Self::InvestmentId, b: &T::AccountId, c: Self::Amount) -> DispatchResult { + execute_call!((a, b, c)) + } + fn transfer( a: Self::InvestmentId, b: &T::AccountId, diff --git a/libs/traits/src/investments.rs b/libs/traits/src/investments.rs index 8e26e93249..910b5a877e 100644 --- a/libs/traits/src/investments.rs +++ b/libs/traits/src/investments.rs @@ -11,6 +11,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +use sp_runtime::DispatchResult; use sp_std::fmt::Debug; /// A trait for converting from a PoolId and a TranchId @@ -178,6 +179,13 @@ pub trait InvestmentAccountant { amount: Self::Amount, ) -> Result<(), Self::Error>; + /// Debit a given amount from the account to the given accountant account + fn debit(id: Self::InvestmentId, from: &AccountId, amount: Self::Amount) -> DispatchResult; + + /// Credit a given amount from the accountant account to the given to + /// account + fn credit(id: Self::InvestmentId, to: &AccountId, amount: Self::Amount) -> DispatchResult; + /// Increases the existence of fn deposit( buyer: &AccountId, diff --git a/pallets/investments/src/lib.rs b/pallets/investments/src/lib.rs index ef9ecd6967..b5a625684d 100644 --- a/pallets/investments/src/lib.rs +++ b/pallets/investments/src/lib.rs @@ -1166,6 +1166,13 @@ impl OrderManager for Pallet { }, )?; + // NOTE: The accountant already receives the current amount of investments + T::Accountant::debit( + investment_id, + &InvestmentAccount { investment_id }.into_account_truncating(), + total_orders.amount, + )?; + let order_id = InvestOrderId::::try_mutate( investment_id, |order_id| -> Result { @@ -1257,13 +1264,8 @@ impl OrderManager for Pallet { InvestmentAccount { investment_id }.into_account_truncating(); let info = T::Accountant::info(investment_id)?; - T::Tokens::transfer( - info.payment_currency, - &investment_account, - &info.owner, - invest_amount, - Preservation::Expendable, - )?; + // NOTE: The accountant gives back what he did not used for the investment + T::Accountant::credit(investment_id, &investment_account, remaining_invest_amount)?; // The amount of investments the accountant needs to // note newly in his books is the invest amount divide through @@ -1349,14 +1351,7 @@ impl OrderManager for Pallet { InvestmentAccount { investment_id }.into_account_truncating(); let info = T::Accountant::info(investment_id)?; - T::Tokens::transfer( - info.payment_currency, - &info.owner, - &investment_account, - redeem_amount_payment, - Preservation::Expendable, - )?; - + T::Accountant::credit(investment_id, &investment_account, redeem_amount_payment)?; T::Accountant::withdraw(&investment_account, info.id, redeem_amount)?; // The previous OrderId is always 1 away diff --git a/pallets/pool-system/src/impls.rs b/pallets/pool-system/src/impls.rs index cf80f26ec8..c15d92c262 100644 --- a/pallets/pool-system/src/impls.rs +++ b/pallets/pool-system/src/impls.rs @@ -360,6 +360,22 @@ impl InvestmentAccountant for Pallet { T::Tokens::transfer(id.into(), source, dest, amount, Preservation::Expendable).map(|_| ()) } + fn debit( + id: Self::InvestmentId, + from: &T::AccountId, + amount: Self::Amount, + ) -> sp_runtime::DispatchResult { + Self::do_debit(id.of_pool(), from, amount) + } + + fn credit( + id: Self::InvestmentId, + to: &T::AccountId, + amount: Self::Amount, + ) -> sp_runtime::DispatchResult { + Self::do_credit(id.of_pool(), to, amount) + } + fn deposit( buyer: &T::AccountId, id: Self::InvestmentId, diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index 097c3ac4d5..791a86595d 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -50,7 +50,7 @@ use sp_runtime::{ EnsureAddAssign, EnsureFixedPointNumber, EnsureSub, EnsureSubAssign, Get, One, Saturating, Zero, }, - DispatchError, FixedPointNumber, FixedPointOperand, Perquintill, TokenError, + DispatchError, FixedPointNumber, FixedPointOperand, Perquintill, }; use sp_std::{cmp::Ordering, vec::Vec}; use tranches::{ @@ -689,7 +689,12 @@ pub mod pallet { epoch_id: submission_period_epoch, }); - // Get the orders + // Get the orders. + // + // NOTE: This will move the total amounts of investments into the reserve, + // making them available for originations. IF the pools does not + // fulfill the order 100%, the reserve is expected to be able + // to re-fund the investment side when executing the epoch. let orders = Self::summarize_orders(&pool.tranches, &epoch_tranche_prices)?; if orders.all_are_zero() { T::OnEpochTransition::on_execution_pre_fulfillments(pool_id)?; @@ -1301,7 +1306,7 @@ pub mod pallet { let pool = pool.as_mut().ok_or(Error::::NoSuchPool)?; let now = T::Time::now(); - pool.reserve.total.ensure_add_assign(amount)?; + Self::do_debit(pool_id, &who, amount)?; let mut remaining_amount = amount; for tranche in pool.tranches.non_residual_top_slice_mut() { @@ -1328,13 +1333,6 @@ pub mod pallet { // TODO: Add a debug log here and/or a debut_assert maybe even an error if // remaining_amount != 0 at this point! - T::Tokens::transfer( - pool.currency, - &who, - &pool_account, - amount, - Preservation::Expendable, - )?; Self::deposit_event(Event::Rebalanced { pool_id }); Ok(()) }) @@ -1350,16 +1348,7 @@ pub mod pallet { let pool = pool.as_mut().ok_or(Error::::NoSuchPool)?; let now = T::Time::now(); - pool.reserve.total = pool - .reserve - .total - .checked_sub(&amount) - .ok_or(TokenError::FundsUnavailable)?; - pool.reserve.available = pool - .reserve - .available - .checked_sub(&amount) - .ok_or(TokenError::FundsUnavailable)?; + Self::do_credit(pool_id, &who, amount)?; let mut remaining_amount = amount; for tranche in pool.tranches.non_residual_top_slice_mut() { @@ -1383,15 +1372,51 @@ pub mod pallet { remaining_amount -= tranche_amount; } + Self::deposit_event(Event::Rebalanced { pool_id }); + + Ok(()) + }) + } + + pub(crate) fn do_credit( + pool_id: T::PoolId, + to: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + Pool::::try_mutate(pool_id, |details| { + let details = details.as_mut().ok_or(Error::::NoSuchPool)?; + T::Tokens::transfer( - pool.currency, - &pool_account, - &who, + details.currency, + &PoolLocator { pool_id }.into_account_truncating(), + to, amount, Preservation::Expendable, - )?; - Self::deposit_event(Event::Rebalanced { pool_id }); - Ok(()) + ) + .map(|_| ())?; + + details.reserve.withdraw(amount) + }) + } + + pub(crate) fn do_debit( + pool_id: T::PoolId, + from: &T::AccountId, + amount: T::Balance, + ) -> DispatchResult { + Pool::::try_mutate(pool_id, |details| { + let details = details.as_mut().ok_or(Error::::NoSuchPool)?; + + T::Tokens::transfer( + details.currency, + from, + &PoolLocator { pool_id }.into_account_truncating(), + amount, + Preservation::Expendable, + ) + .map(|_| ())?; + + details.reserve.deposit(amount) }) } diff --git a/pallets/pool-system/src/pool_types.rs b/pallets/pool-system/src/pool_types.rs index 0594d15ab2..820d2d8540 100644 --- a/pallets/pool-system/src/pool_types.rs +++ b/pallets/pool-system/src/pool_types.rs @@ -52,6 +52,18 @@ impl ReserveDetails where Balance: AtLeast32BitUnsigned + Copy + From, { + pub fn deposit(&mut self, amount: Balance) -> DispatchResult { + self.total = self.total.ensure_add(amount)?; + self.available = self.available.ensure_add(amount)?; + Ok(()) + } + + pub fn withdraw(&mut self, amount: Balance) -> DispatchResult { + self.total = self.total.ensure_sub(amount)?; + self.available = self.available.ensure_sub(amount)?; + Ok(()) + } + /// Update the total balance of the reserve based on the provided solution /// for in- and outflows of this epoc. pub fn deposit_from_epoch( @@ -226,16 +238,11 @@ where pub fn start_next_epoch(&mut self, now: Seconds) -> DispatchResult { self.epoch.current.ensure_add_assign(One::one())?; self.epoch.last_closed = now; - // TODO: Remove and set state rather to EpochClosing or similar - // Set available reserve to 0 to disable originations while the epoch is closed - // but not executed - self.reserve.available = Zero::zero(); Ok(()) } pub fn execute_previous_epoch(&mut self) -> DispatchResult { - self.reserve.available = self.reserve.total; self.epoch .last_executed .ensure_add_assign(One::one()) From 4189b8cc5138cebacb3ce373c67d93a9704096ae Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Thu, 18 Jul 2024 10:33:16 +0200 Subject: [PATCH 2/4] try: make reserve trait based, guide all changes of epoch trhough trait --- libs/traits/src/lib.rs | 10 +++- pallets/pool-fees/src/lib.rs | 21 ++++---- pallets/pool-system/src/lib.rs | 20 ++----- pallets/pool-system/src/pool_types.rs | 77 +++++++++++++++------------ 4 files changed, 68 insertions(+), 60 deletions(-) diff --git a/libs/traits/src/lib.rs b/libs/traits/src/lib.rs index d7eb9042b0..decea3e1dd 100644 --- a/libs/traits/src/lib.rs +++ b/libs/traits/src/lib.rs @@ -332,6 +332,14 @@ pub trait StatusNotificationHook { fn notify_status_change(id: Self::Id, status: Self::Status) -> Result<(), Self::Error>; } +pub trait Reserve { + fn deposit(&mut self, amount: Balance) -> DispatchResult; + + fn withdraw(&mut self, amount: Balance) -> DispatchResult; + + fn total(&self) -> Balance; +} + /// Trait to signal an epoch transition. pub trait EpochTransitionHook { type Balance; @@ -343,7 +351,7 @@ pub trait EpochTransitionHook { fn on_closing_mutate_reserve( pool_id: Self::PoolId, assets_under_management: Self::Balance, - reserve: &mut Self::Balance, + reserve: &mut impl Reserve, ) -> Result<(), Self::Error>; /// Hook into the execution of an epoch before any investment and diff --git a/pallets/pool-fees/src/lib.rs b/pallets/pool-fees/src/lib.rs index 4cf182a03f..420fb1776c 100644 --- a/pallets/pool-fees/src/lib.rs +++ b/pallets/pool-fees/src/lib.rs @@ -35,7 +35,8 @@ pub mod pallet { use cfg_traits::{ changes::ChangeGuard, fee::{FeeAmountProration, PoolFeeBucket, PoolFeesInspect, PoolFeesMutate}, - EpochTransitionHook, PoolInspect, PoolNAV, PoolReserve, PreConditions, Seconds, TimeAsSecs, + EpochTransitionHook, PoolInspect, PoolNAV, PoolReserve, PreConditions, Reserve, Seconds, + TimeAsSecs, }; use cfg_types::{ pools::{ @@ -615,10 +616,10 @@ pub mod pallet { pub(crate) fn update_active_fees( pool_id: T::PoolId, bucket: PoolFeeBucket, - reserve: &mut T::Balance, + reserve: &mut impl Reserve, assets_under_management: T::Balance, epoch_duration: Seconds, - ) -> Result { + ) -> Result, DispatchError> { ActiveFees::::mutate(pool_id, bucket, |fees| { for fee in fees.iter_mut() { let limit = fee.amounts.limit(); @@ -648,8 +649,8 @@ pub mod pallet { .map_err(|e: DispatchError| e)?; // Disbursement amount is limited by reserve - let disbursement = fee_amount.min(*reserve); - reserve.ensure_sub_assign(disbursement)?; + let disbursement = fee_amount.min(reserve.total()); + reserve.withdraw(disbursement)?; // Update fee amounts fee.amounts.pending.ensure_sub_assign(disbursement)?; @@ -672,7 +673,7 @@ pub mod pallet { Ok::<(), DispatchError>(()) })?; - Ok(*reserve) + Ok(reserve) } /// Entirely remove a stored fee from the given pair of pool id and fee @@ -738,7 +739,7 @@ pub mod pallet { /// ``` pub fn update_portfolio_valuation_for_pool( pool_id: T::PoolId, - reserve: &mut T::Balance, + reserve: &mut impl Reserve, ) -> Result<(T::Balance, u32), DispatchError> { let fee_nav = PortfolioValuation::::get(pool_id); let aum = AssetsUnderManagement::::get(pool_id); @@ -859,17 +860,17 @@ pub mod pallet { fn on_closing_mutate_reserve( pool_id: Self::PoolId, assets_under_management: Self::Balance, - reserve: &mut Self::Balance, + reserve: &mut impl Reserve, ) -> Result<(), Self::Error> { // Determine pending fees and NAV based on last epoch's AUM - let res_pre_fees = *reserve; + let res_pre_fees = reserve.total(); 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); + let total_fee_amount = res_pre_fees.saturating_sub(reserve.total()); if !total_fee_amount.is_zero() { let pool_currency = T::PoolReserve::currency_for(pool_id).ok_or(Error::::PoolNotFound)?; diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index 791a86595d..ca12218c10 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -112,17 +112,7 @@ pub type TrancheOf = Tranche< >; /// Type alias to ease function signatures -pub type PoolDetailsOf = PoolDetails< - ::CurrencyId, - ::TrancheCurrency, - ::EpochId, - ::Balance, - ::Rate, - ::TrancheWeight, - ::TrancheId, - ::PoolId, - ::MaxTranches, ->; +pub type PoolDetailsOf = PoolDetails; /// Type alias for `struct EpochExecutionInfo` type EpochExecutionInfoOf = EpochExecutionInfo< @@ -174,7 +164,7 @@ pub mod pallet { use cfg_traits::{ fee::{PoolFeeBucket, PoolFeesInspect, PoolFeesMutate}, investments::{OrderManager, TrancheCurrency as TrancheCurrencyT}, - EpochTransitionHook, PoolUpdateGuard, + EpochTransitionHook, PoolUpdateGuard, Reserve, }; use cfg_types::{ orders::{FulfillmentWithPrice, TotalOrder}, @@ -643,7 +633,7 @@ pub mod pallet { T::OnEpochTransition::on_closing_mutate_reserve( pool_id, nav_aum, - &mut pool.reserve.total, + &mut pool.reserve, )?; let (nav_fees, fees_last_updated) = T::PoolFeesNAV::nav(pool_id).ok_or(Error::::NoNAV)?; @@ -653,7 +643,7 @@ pub mod pallet { ); let nav = Nav::new(nav_aum, nav_fees); let nav_total = nav - .total(pool.reserve.total) + .total(pool.reserve.total()) // NOTE: From an accounting perspective, erroring out would be correct. However, // since investments of this epoch are included in the reserve only in the next // epoch, every new pool with a configured fee is likely to be blocked if we @@ -663,7 +653,7 @@ pub mod pallet { pool_id, nav_aum, nav_fees, - reserve: pool.reserve.total, + reserve: pool.reserve.total(), }); }) .unwrap_or(T::Balance::default()); diff --git a/pallets/pool-system/src/pool_types.rs b/pallets/pool-system/src/pool_types.rs index 820d2d8540..b1dc7bf018 100644 --- a/pallets/pool-system/src/pool_types.rs +++ b/pallets/pool-system/src/pool_types.rs @@ -29,8 +29,12 @@ use sp_runtime::{ }; use sp_std::{cmp::PartialEq, vec::Vec}; -use crate::tranches::{ - EpochExecutionTranches, TrancheEssence, TrancheInput, TrancheSolution, TrancheUpdate, Tranches, +use crate::{ + tranches::{ + EpochExecutionTranches, TrancheEssence, TrancheInput, TrancheSolution, TrancheUpdate, + Tranches, + }, + Config, Error, }; // The TypeId impl we derive pool-accounts from @@ -39,31 +43,43 @@ impl TypeId for PoolLocator { } #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct ReserveDetails { +pub struct ReserveDetails { /// Investments will be allowed up to this amount. - pub max: Balance, + pub max: T::Balance, /// Current total amount of currency in the pool reserve. - pub total: Balance, + pub total: T::Balance, /// Current reserve that is available for originations. - pub available: Balance, + pub available: T::Balance, } -impl ReserveDetails -where - Balance: AtLeast32BitUnsigned + Copy + From, -{ - pub fn deposit(&mut self, amount: Balance) -> DispatchResult { +impl cfg_traits::Reserve for ReserveDetails { + fn deposit(&mut self, amount: T::Balance) -> DispatchResult { self.total = self.total.ensure_add(amount)?; self.available = self.available.ensure_add(amount)?; Ok(()) } - pub fn withdraw(&mut self, amount: Balance) -> DispatchResult { - self.total = self.total.ensure_sub(amount)?; - self.available = self.available.ensure_sub(amount)?; + fn withdraw(&mut self, amount: T::Balance) -> DispatchResult { + self.total = self + .total + .ensure_sub(amount) + .map_err(Error::InsufficientCurrency)?; + self.available = self + .available + .ensure_sub(amount) + .map_err(Error::InsufficientCurrency)?; Ok(()) } + fn total(&self) -> T::Balance { + self.total + } +} + +impl ReserveDetails +where + Balance: AtLeast32BitUnsigned + Copy + From, +{ /// Update the total balance of the reserve based on the provided solution /// for in- and outflows of this epoc. pub fn deposit_from_epoch( @@ -118,34 +134,27 @@ pub struct PoolLocator { } #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct PoolDetails< - CurrencyId, - TrancheCurrency, - EpochId, - Balance, - Rate, - Weight, - TrancheId, - PoolId, - MaxTranches, -> where - Rate: FixedPointNumber, - Balance: FixedPointOperand + sp_arithmetic::MultiplyRational, - MaxTranches: Get, - TrancheCurrency: Into, -{ +pub struct PoolDetails { /// Currency that the pool is denominated in (immutable). - pub currency: CurrencyId, + pub currency: T::CurrencyId, /// List of tranches, ordered junior to senior. - pub tranches: Tranches, + pub tranches: Tranches< + T::Balance, + T::Rate, + T::TrancheWeight, + T::TrancheCurrency, + T::TrancheId, + T::PoolId, + T::MaxTranches, + >, /// Details about the parameters of the pool. pub parameters: PoolParameters, /// The status the pool is currently in. pub status: PoolStatus, /// Details about the epochs of the pool. - pub epoch: EpochState, + pub epoch: EpochState, /// Details about the reserve (unused capital) in the pool. - pub reserve: ReserveDetails, + pub reserve: ReserveDetails, } #[derive(Clone, Encode, Decode, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] From f920e9b37b36d359813b9814a1e871a93ca86146 Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Fri, 19 Jul 2024 09:35:51 +0200 Subject: [PATCH 3/4] try: execution part - missing scoring --- pallets/pool-system/src/lib.rs | 376 ++++++++++++-------------- pallets/pool-system/src/pool_types.rs | 48 +--- pallets/pool-system/src/solution.rs | 27 +- pallets/pool-system/src/tranches.rs | 2 - 4 files changed, 184 insertions(+), 269 deletions(-) diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index ca12218c10..9c6b4c0a74 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -117,11 +117,8 @@ pub type PoolDetailsOf = PoolDetails; /// Type alias for `struct EpochExecutionInfo` type EpochExecutionInfoOf = EpochExecutionInfo< ::Balance, - ::BalanceRatio, ::EpochId, - ::TrancheWeight, BlockNumberFor, - ::TrancheCurrency, ::MaxTranches, >; @@ -621,59 +618,36 @@ pub mod pallet { Error::::MinEpochTimeHasNotPassed ); - // Get positive NAV from AUM - let (nav_aum, aum_last_updated) = - T::AssetsUnderManagementNAV::nav(pool_id).ok_or(Error::::NoNAV)?; - ensure!( - now.saturating_sub(aum_last_updated) <= pool.parameters.max_nav_age, - Error::::NAVTooOld - ); + let nav = Self::get_nav(pool_id, |nav_aum| { + T::OnEpochTransition::on_closing_mutate_reserve( + pool_id, + nav_aum, + &mut pool.reserve, + ) + })?; - // Calculate fees to get negative NAV - T::OnEpochTransition::on_closing_mutate_reserve( - pool_id, - nav_aum, - &mut pool.reserve, + let prices = Self::calculate_prices( + &mut pool.tranches, + nav.total(pool.reserve.total()) + // NOTE: From an accounting perspective, erroring out would be correct. + // However, since investments of this epoch are included in the + // reserve only in the next epoch, every new pool with a configured + // fee is likely to be blocked if we threw an error here. Thus, we + // dispatch an event as a defensive workaround. + .map_err(|_| { + Self::deposit_event(Event::NegativeBalanceSheet { + pool_id, + nav_aum, + nav_fees, + reserve: pool.reserve.total(), + }); + }) + .unwrap_or(T::Balance::default()), )?; - 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()) - // NOTE: From an accounting perspective, erroring out would be correct. However, - // since investments of this epoch are included in the reserve only in the next - // epoch, every new pool with a configured fee is likely to be blocked if we - // threw an error here. Thus, we dispatch an event as a defensive workaround. - .map_err(|_| { - Self::deposit_event(Event::NegativeBalanceSheet { - pool_id, - nav_aum, - nav_fees, - reserve: pool.reserve.total(), - }); - }) - .unwrap_or(T::Balance::default()); - let submission_period_epoch = pool.epoch.current; + let submission_period_epoch = pool.epoch.current; pool.start_next_epoch(now)?; - let epoch_tranche_prices = pool - .tranches - .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 - ensure!( - !epoch_tranche_prices - .iter() - .any(|price| *price == Zero::zero()), - Error::::WipedOut - ); - Self::deposit_event(Event::EpochClosed { pool_id, epoch_id: submission_period_epoch, @@ -685,129 +659,44 @@ pub mod pallet { // making them available for originations. IF the pools does not // fulfill the order 100%, the reserve is expected to be able // to re-fund the investment side when executing the epoch. - let orders = Self::summarize_orders(&pool.tranches, &epoch_tranche_prices)?; - if orders.all_are_zero() { - T::OnEpochTransition::on_execution_pre_fulfillments(pool_id)?; - - pool.tranches.combine_with_mut_residual_top( - &epoch_tranche_prices, - |tranche, price| { - let zero_fulfillment = FulfillmentWithPrice { - of_amount: Perquintill::zero(), - price: *price, - }; - T::Investments::invest_fulfillment(tranche.currency, zero_fulfillment)?; - T::Investments::redeem_fulfillment(tranche.currency, zero_fulfillment) - }, - )?; - - pool.execute_previous_epoch()?; - - Self::deposit_event(Event::EpochExecuted { - pool_id, - epoch_id: submission_period_epoch, - }); - - return Ok(Some(T::WeightInfo::close_epoch_no_orders( - pool.tranches - .num_tranches() - .try_into() - .expect("MaxTranches is u32. qed."), - T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), - )) - .into()); - } - - let epoch_tranches: Vec> = - pool.tranches.combine_with_residual_top( - epoch_tranche_prices - .iter() - .zip(orders.invest_redeem_residual_top()), - |tranche, (price, (invest, redeem))| { - let epoch_tranche = EpochExecutionTranche { - currency: tranche.currency, - supply: tranche.balance()?, - price: *price, - invest, - redeem, - seniority: tranche.seniority, - min_risk_buffer: tranche.min_risk_buffer(), - _phantom: Default::default(), - }; - - Ok(epoch_tranche) - }, - )?; + let orders = Self::summarize_orders(&pool.tranches, &prices)?; let mut epoch = EpochExecutionInfo { - nav, + orders, epoch: submission_period_epoch, - tranches: EpochExecutionTranches::new(epoch_tranches), best_submission: None, challenge_period_end: None, }; - let full_execution_solution = pool.tranches.combine_residual_top(|_| { + let no_execution_solution = pool.tranches.combine_residual_top(|_| { Ok(TrancheSolution { - invest_fulfillment: Perquintill::one(), - redeem_fulfillment: Perquintill::one(), + invest_fulfillment: Perquintill::zero(), + redeem_fulfillment: Perquintill::zero(), }) })?; - if Self::inspect_solution(pool, &epoch, &full_execution_solution) - .map(|state| state == PoolState::Healthy) - .unwrap_or(false) - { - Self::do_execute_epoch(pool_id, pool, &epoch, &full_execution_solution)?; - Self::deposit_event(Event::EpochExecuted { - pool_id, - epoch_id: submission_period_epoch, - }); - Ok(Some(T::WeightInfo::close_epoch_execute( - pool.tranches - .num_tranches() - .try_into() - .expect("MaxTranches is u32. qed."), - T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), - )) - .into()) - } else { - // Any new submission needs to improve on the existing state (which is defined - // as a total fulfilment of 0%) - let no_execution_solution = pool.tranches.combine_residual_top(|_| { - Ok(TrancheSolution { - invest_fulfillment: Perquintill::zero(), - redeem_fulfillment: Perquintill::zero(), - }) - })?; - - let existing_state_solution = - Self::score_solution(pool, &epoch, &no_execution_solution)?; - epoch.best_submission = Some(existing_state_solution); - EpochExecution::::insert(pool_id, epoch); - - Ok(Some(T::WeightInfo::close_epoch_no_execution( - pool.tranches - .num_tranches() - .try_into() - .expect("MaxTranches is u32. qed."), - T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), - )) - .into()) - } + let existing_state_solution = + Self::score_solution(pool, &epoch, &no_execution_solution)?; + epoch.best_submission = Some(existing_state_solution); + EpochExecution::::insert(pool_id, epoch); + + Ok(Some(T::WeightInfo::close_epoch_no_execution( + pool.tranches + .num_tranches() + .try_into() + .expect("MaxTranches is u32. qed."), + T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), + )) + .into()) }) } - /// Submit a partial execution solution for a closed epoch + /// Submit a execution solution for a closed epoch /// /// If the submitted solution is "better" than the /// previous best solution, it will replace it. Solutions /// are ordered such that solutions which do not violate /// constraints are better than those that do. - /// - /// 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(), T::PoolFees::get_max_fees_per_bucket() @@ -818,7 +707,7 @@ pub mod pallet { pool_id: T::PoolId, solution: Vec, ) -> DispatchResultWithPostInfo { - ensure_signed(origin)?; + T::AdminOrigin::ensure_origin(origin, &pool_id)?; EpochExecution::::try_mutate(pool_id, |epoch| { let epoch = epoch.as_mut().ok_or(Error::::NotInSubmissionPeriod)?; @@ -834,11 +723,14 @@ pub mod pallet { epoch.best_submission = Some(new_solution.clone()); + // TODO: We might wanna re-enable that at some point WHEN we enable epoch + // closing and execution from all but the admin again + // // Challenge period starts when the first new solution has been submitted - if epoch.challenge_period_end.is_none() { - epoch.challenge_period_end = - Some(Self::current_block().saturating_add(T::ChallengeTime::get())); - } + // if epoch.challenge_period_end.is_none() { + // epoch.challenge_period_end = + // Some(Self::current_block().saturating_add(T::ChallengeTime::get())); + // } Self::deposit_event(Event::SolutionSubmitted { pool_id, @@ -881,39 +773,26 @@ pub mod pallet { .as_mut() .ok_or(Error::::NotInSubmissionPeriod)?; - ensure!( - epoch.best_submission.is_some(), - Error::::NoSolutionAvailable - ); + let solution = if let Some(best_submission) = &epoch.best_submission { + best_submission.solution() + } else { + return Err(Error::::NoSolutionAvailable); + }; - // The challenge period is some if we have submitted at least one valid - // solution since going into submission period. Hence, if it is none - // no solution beside the injected zero-solution is available. - ensure!( - epoch.challenge_period_end.is_some(), - Error::::NoSolutionAvailable - ); + if let Some(challenge_period_end) = epoch.challenge_period_end { + ensure!( + challenge_period_end <= Self::current_block(), + Error::::ChallengeTimeHasNotPassed + ); + } - ensure!( - epoch - .challenge_period_end - .expect("Challenge period is some. qed.") - <= Self::current_block(), - Error::::ChallengeTimeHasNotPassed - ); + // TODO: Ensure that no originations between solution and execution -> Hash of + // reserve should suffice - // TODO: Write a test for the `expect` in case we allow the removal of pools at - // some point Pool::::try_mutate(pool_id, |pool| -> DispatchResult { let pool = pool .as_mut() - .expect("EpochExecutionInfo can only exist on existing pools. qed."); - - let solution = &epoch - .best_submission - .as_ref() - .expect("Solution exists. qed.") - .solution(); + .map_err("EpochExecutionInfo can only exist on existing pools. qed.")?; Self::do_execute_epoch(pool_id, pool, epoch, solution)?; Self::deposit_event(Event::EpochExecuted { @@ -923,17 +802,14 @@ pub mod pallet { Ok(()) })?; - let num_tranches = epoch - .tranches - .num_tranches() - .try_into() - .expect("MaxTranches is u32. qed."); - - // 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, + epoch + .tranches + .num_tranches() + .try_into() + .map_err("TryInto u32 failed. Never should happen. qed.")?, T::PoolFees::get_pool_fee_bucket_count(pool_id, PoolFeeBucket::Top), )) .into()) @@ -1009,7 +885,7 @@ pub mod pallet { solution: &[TrancheSolution], ) -> Result { ensure!( - solution.len() == epoch.tranches.num_tranches(), + solution.len() == pool.tranches.num_tranches(), Error::::InvalidSolution ); @@ -1033,7 +909,7 @@ pub mod pallet { })?; let currency_available: T::Balance = acc_invest - .checked_add(&pool.reserve.total) + .checked_add(&pool.reserve.total()) .ok_or(Error::::InvalidSolution)?; let new_reserve = currency_available @@ -1203,6 +1079,50 @@ pub mod pallet { Ok(()) } + fn get_nav( + pool_id: T::PoolId, + between: impl FnOnce(T::Balance) -> DispatchResult, + ) -> Result, DispatchError> { + let now = T::Time::now(); + + // Get positive NAV from AUM + let (nav_aum, aum_last_updated) = + T::AssetsUnderManagementNAV::nav(pool_id).ok_or(Error::::NoNAV)?; + ensure!( + now.saturating_sub(aum_last_updated) <= pool.parameters.max_nav_age, + Error::::NAVTooOld + ); + + between(nav_aum)?; + + 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 + ); + + Ok(Nav::new(nav_aum, nav_fees)) + } + + fn calculate_prices( + tranches: &mut TranchesOf, + nav_total: T::Balance, + ) -> Result, DispatchError> { + let prices = tranches + .calculate_prices::(nav_total, T::Time::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 + ensure!( + !prices.iter().any(|price| *price == Zero::zero()), + Error::::WipedOut + ); + + Ok(prices) + } + fn do_execute_epoch( pool_id: T::PoolId, pool: &mut PoolDetailsOf, @@ -1211,14 +1131,40 @@ pub mod pallet { ) -> DispatchResult { T::OnEpochTransition::on_execution_pre_fulfillments(pool_id)?; - pool.reserve.deposit_from_epoch(&epoch.tranches, solution)?; + let nav = Self::get_nav(pool_id, |_| Ok(()))?; + let prices = Self::calculate_prices( + &mut pool.tranches, + nav.total(pool.reserve.total()) + // NOTE: From an accounting perspective, erroring out would be correct. + // However, since investments of this epoch are included in the + // reserve only in the next epoch, every new pool with a configured + // fee is likely to be blocked if we threw an error here. Thus, we + // dispatch an event as a defensive workaround. + .map_err(|_| { + Self::deposit_event(Event::NegativeBalanceSheet { + pool_id, + nav_aum, + nav_fees, + reserve: pool.reserve.total(), + }); + }) + .unwrap_or(T::Balance::default()), + )?; - for (tranche, solution) in epoch.tranches.residual_top_slice().iter().zip(solution) { + // NOTE: Calling invest_fulfillment() will take care of re-transferring the + // unfulfilled funds from the reserve to the investment pallet. + for ((tranche, solution), price) in pool + .tranches + .residual_top_slice() + .iter() + .zip(solution) + .zip(prices) + { T::Investments::invest_fulfillment( tranche.currency, FulfillmentWithPrice { of_amount: solution.invest_fulfillment, - price: tranche.price, + price, }, )?; @@ -1233,26 +1179,38 @@ pub mod pallet { pool.execute_previous_epoch()?; - let executed_amounts = epoch.tranches.fulfillment_cash_flows(solution)?; - let total_assets = epoch.nav.total(pool.reserve.total)?; + let executed_amounts = + pool.tranches + .combine_with_residual_top(solution, |tranche, solution| { + Ok(( + solution.invest_fulfillment.mul_floor(tranche.invest), + solution.redeem_fulfillment.mul_floor(tranche.redeem), + )) + }); + let total_assets = nav.total(pool.reserve.total())?; + + let now = T::Time::now(); let tranche_ratios = { let mut sum_non_residual_tranche_ratios = Perquintill::zero(); let num_tranches = pool.tranches.num_tranches(); let mut current_tranche = 1; - let mut ratios = epoch + let mut ratios = pool .tranches // NOTE: Reversing amounts, as residual amount is on top. - .combine_with_non_residual_top( + .combine_with_mut_non_residual_top( executed_amounts.rev(), |tranche, &(invest, redeem)| { + // Update tranche debt + tranche.accrue(now)?; + // NOTE: Need to have this clause as the current Perquintill // implementation defaults to 100% if the denominator is zero let ratio = if total_assets.is_zero() { Perquintill::zero() } else if current_tranche < num_tranches { Perquintill::from_rational( - tranche.supply.ensure_add(invest)?.ensure_sub(redeem)?, + tranche.balance().ensure_add(invest)?.ensure_sub(redeem)?, total_assets, ) } else { @@ -1274,9 +1232,9 @@ pub mod pallet { }; pool.tranches.rebalance_tranches( - T::Time::now(), - pool.reserve.total, - epoch.nav.nav_aum, + now, + pool.reserve.total(), + nav.aum(), tranche_ratios.as_slice(), &executed_amounts, )?; diff --git a/pallets/pool-system/src/pool_types.rs b/pallets/pool-system/src/pool_types.rs index b1dc7bf018..ec2bcf10a4 100644 --- a/pallets/pool-system/src/pool_types.rs +++ b/pallets/pool-system/src/pool_types.rs @@ -10,7 +10,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -use cfg_traits::Seconds; +use cfg_traits::{Reserve, Seconds}; use cfg_types::{epoch::EpochState, pools::TrancheMetadata}; pub use changes::PoolChangeProposal; use frame_support::{ @@ -47,12 +47,12 @@ pub struct ReserveDetails { /// Investments will be allowed up to this amount. pub max: T::Balance, /// Current total amount of currency in the pool reserve. - pub total: T::Balance, + total: T::Balance, /// Current reserve that is available for originations. - pub available: T::Balance, + available: T::Balance, } -impl cfg_traits::Reserve for ReserveDetails { +impl Reserve for ReserveDetails { fn deposit(&mut self, amount: T::Balance) -> DispatchResult { self.total = self.total.ensure_add(amount)?; self.available = self.available.ensure_add(amount)?; @@ -76,46 +76,6 @@ impl cfg_traits::Reserve for ReserveDetails { } } -impl ReserveDetails -where - Balance: AtLeast32BitUnsigned + Copy + From, -{ - /// Update the total balance of the reserve based on the provided solution - /// for in- and outflows of this epoc. - pub fn deposit_from_epoch( - &mut self, - epoch_tranches: &EpochExecutionTranches< - Balance, - BalanceRatio, - Weight, - TrancheCurrency, - MaxExecutionTranches, - >, - solution: &[TrancheSolution], - ) -> DispatchResult - where - Weight: Copy + From, - BalanceRatio: Copy, - MaxExecutionTranches: Get, - { - let executed_amounts = epoch_tranches.fulfillment_cash_flows(solution)?; - - // Update the total/available reserve for the new total value of the pool - let mut acc_investments = Balance::zero(); - let mut acc_redemptions = Balance::zero(); - for &(invest, redeem) in executed_amounts.iter() { - acc_investments.ensure_add_assign(invest)?; - acc_redemptions.ensure_add_assign(redeem)?; - } - self.total = self - .total - .ensure_add(acc_investments)? - .ensure_sub(acc_redemptions)?; - - Ok(()) - } -} - #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct ScheduledUpdateDetails where diff --git a/pallets/pool-system/src/solution.rs b/pallets/pool-system/src/solution.rs index a22a73656f..cda78da8e9 100644 --- a/pallets/pool-system/src/solution.rs +++ b/pallets/pool-system/src/solution.rs @@ -112,8 +112,8 @@ where #[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, + nav_aum: Balance, + nav_fees: Balance, } impl Nav { @@ -127,25 +127,24 @@ impl Nav { .ensure_sub(self.nav_fees) .map_err(Into::into) } + + pub fn aum(&self) -> Balance { + self.nav_aum + } + + pub fn fees(&self) -> Balance { + self.nav_fees + } } /// The information for a currently executing epoch #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] -pub struct EpochExecutionInfo< - Balance, - BalanceRatio, - EpochId, - Weight, - BlockNumber, - TrancheCurrency, - MaxTranches, -> where +pub struct EpochExecutionInfo +where MaxTranches: Get, { pub epoch: EpochId, - pub nav: Nav, - pub tranches: - EpochExecutionTranches, + pub orders: SummarizedOrders, pub best_submission: Option>, pub challenge_period_end: Option, } diff --git a/pallets/pool-system/src/tranches.rs b/pallets/pool-system/src/tranches.rs index 9b7011ca13..ac8322b51a 100644 --- a/pallets/pool-system/src/tranches.rs +++ b/pallets/pool-system/src/tranches.rs @@ -978,8 +978,6 @@ where #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub struct EpochExecutionTranche { pub currency: TrancheCurrency, - pub supply: Balance, - pub price: BalanceRatio, pub invest: Balance, pub redeem: Balance, pub min_risk_buffer: Perquintill, From 967116c4a2135d76f6a0865022aedfff5a74f167 Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Fri, 19 Jul 2024 15:34:58 +0200 Subject: [PATCH 4/4] wip --- pallets/pool-system/src/lib.rs | 29 ++++++++++++++++++++++++----- pallets/pool-system/src/solution.rs | 1 + 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pallets/pool-system/src/lib.rs b/pallets/pool-system/src/lib.rs index 9c6b4c0a74..6262afe997 100644 --- a/pallets/pool-system/src/lib.rs +++ b/pallets/pool-system/src/lib.rs @@ -666,6 +666,7 @@ pub mod pallet { epoch: submission_period_epoch, best_submission: None, challenge_period_end: None, + dirty: false, }; let no_execution_solution = pool.tranches.combine_residual_top(|_| { @@ -722,6 +723,7 @@ pub mod pallet { } epoch.best_submission = Some(new_solution.clone()); + epoch.dirty = false; // TODO: We might wanna re-enable that at some point WHEN we enable epoch // closing and execution from all but the admin again @@ -786,8 +788,7 @@ pub mod pallet { ); } - // TODO: Ensure that no originations between solution and execution -> Hash of - // reserve should suffice + ensure!(!epoch.dirty, Error::::InvalidSolution); Pool::::try_mutate(pool_id, |pool| -> DispatchResult { let pool = pool @@ -1283,7 +1284,9 @@ pub mod pallet { Self::deposit_event(Event::Rebalanced { pool_id }); Ok(()) - }) + })?; + + Self::mark_dirty(pool_id) } pub(crate) fn do_withdraw( @@ -1323,7 +1326,9 @@ pub mod pallet { Self::deposit_event(Event::Rebalanced { pool_id }); Ok(()) - }) + })?; + + Self::mark_dirty(pool_id) } pub(crate) fn do_credit( @@ -1344,7 +1349,9 @@ pub mod pallet { .map(|_| ())?; details.reserve.withdraw(amount) - }) + })?; + + Self::mark_dirty(pool_id) } pub(crate) fn do_debit( @@ -1365,6 +1372,18 @@ pub mod pallet { .map(|_| ())?; details.reserve.deposit(amount) + })?; + + Self::mark_dirty(pool_id) + } + + fn mark_dirty(pool_id: T::PoolId) -> DispatchResult { + // Need to mark the solution as dirty, as the state has changed + EpochExecution::::try_mutate(pool_id, |epoch_info| { + if let Some(epoch) = epoch_info.as_mut() { + epoch.dirty = true; + } + Ok(()) }) } diff --git a/pallets/pool-system/src/solution.rs b/pallets/pool-system/src/solution.rs index cda78da8e9..352847482d 100644 --- a/pallets/pool-system/src/solution.rs +++ b/pallets/pool-system/src/solution.rs @@ -147,6 +147,7 @@ where pub orders: SummarizedOrders, pub best_submission: Option>, pub challenge_period_end: Option, + pub dirty: bool, } impl EpochSolution