From 978d18d9368ca146eb96810539e794d7c9ac0420 Mon Sep 17 00:00:00 2001 From: Frederik Gartenmeister Date: Fri, 19 Jul 2024 09:35:51 +0200 Subject: [PATCH] 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,