diff --git a/Cargo.lock b/Cargo.lock index 43b3ced781..adf59b9c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,7 @@ dependencies = [ "pallet-timestamp", "parity-scale-codec 3.6.9", "scale-info", + "sp-arithmetic", "sp-consensus-aura", "sp-runtime", "sp-std", @@ -7479,6 +7480,7 @@ dependencies = [ "cfg-primitives", "cfg-traits", "cfg-types", + "cfg-utils", "frame-benchmarking", "frame-support", "frame-system", diff --git a/libs/mocks/src/data.rs b/libs/mocks/src/data.rs index 8fbcb7d65e..98e887f3c4 100644 --- a/libs/mocks/src/data.rs +++ b/libs/mocks/src/data.rs @@ -97,6 +97,14 @@ pub mod pallet { } } + impl Default for MockDataCollection { + fn default() -> Self { + Self(Box::new(|_| { + Err(DispatchError::Other("MockDataCollection: Data not found")) + })) + } + } + impl DataCollection for MockDataCollection { type Data = Data; diff --git a/libs/traits/src/data.rs b/libs/traits/src/data.rs index bd5400315b..922efac3a5 100644 --- a/libs/traits/src/data.rs +++ b/libs/traits/src/data.rs @@ -37,7 +37,7 @@ pub trait DataRegistry { } /// Abstration to represent a collection of data in memory -pub trait DataCollection { +pub trait DataCollection: Default { /// Represents a data type Data; diff --git a/libs/utils/Cargo.toml b/libs/utils/Cargo.toml index 6c5c92c8c7..31c1f028b1 100644 --- a/libs/utils/Cargo.toml +++ b/libs/utils/Cargo.toml @@ -20,6 +20,7 @@ pallet-aura = { workspace = true } pallet-timestamp = { workspace = true } parity-scale-codec = { workspace = true } scale-info = { workspace = true } +sp-arithmetic = { workspace = true } sp-consensus-aura = { workspace = true } sp-runtime = { workspace = true } sp-std = { workspace = true } @@ -30,6 +31,7 @@ std = [ "frame-support/std", "frame-system/std", "sp-runtime/std", + "sp-arithmetic/std", "sp-std/std", "pallet-timestamp/std", "pallet-aura/std", diff --git a/libs/utils/src/lib.rs b/libs/utils/src/lib.rs index 8491df64e9..6986b37723 100644 --- a/libs/utils/src/lib.rs +++ b/libs/utils/src/lib.rs @@ -112,6 +112,101 @@ pub fn decode_var_source( } } +pub mod math { + use sp_arithmetic::{ + traits::{BaseArithmetic, EnsureFixedPointNumber}, + ArithmeticError, FixedPointOperand, FixedU128, + }; + + /// Returns the coordinate `y` for coordinate `x`, + /// in a function given by 2 points: (x1, y1) and (x2, y2) + pub fn y_coord_in_rect( + (x1, y1): (X, Y), + (x2, y2): (X, Y), + x: X, + ) -> Result + where + X: BaseArithmetic + FixedPointOperand, + Y: BaseArithmetic + FixedPointOperand, + { + // From the equation: (x - x1) / (x2 - x1) == (y - y1) / (y2 - y1) we solve y: + // + // NOTE: With rects that have x or y negative directions, we emulate a + // symmetry in those axis to avoid unsigned underflows in substractions. It + // means, we first "convert" the rect into an increasing rect, and in such rect, + // we find the y coordinate. + + let left = if x1 <= x2 { + FixedU128::ensure_from_rational(x.ensure_sub(x1)?, x2.ensure_sub(x1)?)? + } else { + // X symmetry emulation + FixedU128::ensure_from_rational(x1.ensure_sub(x)?, x1.ensure_sub(x2)?)? + }; + + if y1 <= y2 { + left.ensure_mul_int(y2.ensure_sub(y1)?)?.ensure_add(y1) + } else { + // Y symmetry emulation + y1.ensure_sub(left.ensure_mul_int(y1.ensure_sub(y2)?)?) + } + } + + #[cfg(test)] + mod test_y_coord_in_function_with_2_points { + use super::*; + + #[test] + fn start_point() { + assert_eq!(y_coord_in_rect::((3, 12), (7, 24), 3), Ok(12)); + } + + #[test] + fn end_point() { + assert_eq!(y_coord_in_rect::((3, 12), (7, 24), 7), Ok(24)); + } + + // Rect defined as: + // (x2, y2) + // / + // / + // (x1, y1) + #[test] + fn inner_point() { + assert_eq!(y_coord_in_rect::((3, 12), (7, 24), 4), Ok(15)); + } + + // Rect defined as: + // (x2, y2) + // \ + // \ + // (x1, y1) + #[test] + fn inner_point_with_greater_x1() { + assert_eq!(y_coord_in_rect::((7, 12), (3, 24), 4), Ok(21)); + } + + // Rect defined as: + // (x1, y1) + // \ + // \ + // (x2, y2) + #[test] + fn inner_point_with_greater_y1() { + assert_eq!(y_coord_in_rect::((3, 24), (7, 12), 4), Ok(21)); + } + + // Rect defined as: + // (x1, y1) + // / + // / + // (x2, y2) + #[test] + fn inner_point_with_greater_x1y1() { + assert_eq!(y_coord_in_rect::((7, 24), (3, 12), 4), Ok(15)); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index 34f59d8fef..5934cd764e 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -25,6 +25,7 @@ sp-std = { workspace = true } cfg-primitives = { workspace = true } cfg-traits = { workspace = true } cfg-types = { workspace = true } +cfg-utils = { workspace = true } orml-traits = { workspace = true } strum = { workspace = true } @@ -56,6 +57,7 @@ std = [ "cfg-primitives/std", "cfg-traits/std", "cfg-types/std", + "cfg-utils/std", "frame-benchmarking/std", "strum/std", "orml-traits/std", @@ -68,6 +70,7 @@ runtime-benchmarks = [ "cfg-primitives/runtime-benchmarks", "cfg-traits/runtime-benchmarks", "cfg-types/runtime-benchmarks", + "cfg-utils/runtime-benchmarks", "pallet-uniques/runtime-benchmarks", "cfg-mocks/runtime-benchmarks", ] @@ -78,6 +81,7 @@ try-runtime = [ "cfg-primitives/try-runtime", "cfg-traits/try-runtime", "cfg-types/try-runtime", + "cfg-utils/try-runtime", "cfg-mocks/try-runtime", "sp-runtime/try-runtime", ] diff --git a/pallets/loans/docs/types.md b/pallets/loans/docs/types.md index 3ae87a7ded..3ec16a509c 100644 --- a/pallets/loans/docs/types.md +++ b/pallets/loans/docs/types.md @@ -168,6 +168,7 @@ package pricing { outstanding_quantity: Rate, interest: ActiveInterestRate latest_settlement_price: Balance, + settlement_price_updated: Seconds, } ExternalActivePricing *-r-> ActiveInterestRate diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index b0f9b0ce25..a261310461 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -2,7 +2,7 @@ use cfg_traits::{ self, data::DataCollection, interest::{InterestAccrual, InterestRate, RateCollection}, - IntoSeconds, Seconds, TimeAsSecs, + Seconds, TimeAsSecs, }; use cfg_types::adjustments::Adjustment; use frame_support::{ensure, pallet_prelude::DispatchResult, RuntimeDebugNoBound}; @@ -255,23 +255,21 @@ impl ActiveLoan { Ok(now >= self.maturity_date().ensure_add(*overdue_secs)?) } WriteOffTrigger::PriceOutdated(secs) => match &self.pricing { - ActivePricing::External(pricing) => Ok(now - >= pricing - .last_updated(pool_id)? - .into_seconds() - .ensure_add(*secs)?), + ActivePricing::External(pricing) => { + Ok(now >= pricing.last_updated(pool_id).ensure_add(*secs)?) + } ActivePricing::Internal(_) => Ok(false), }, } } pub fn present_value(&self, pool_id: T::PoolId) -> Result { + let maturity_date = self.schedule.maturity.date(); let value = match &self.pricing { ActivePricing::Internal(inner) => { - let maturity_date = self.schedule.maturity.date(); inner.present_value(self.origination_date, maturity_date)? } - ActivePricing::External(inner) => inner.present_value(pool_id)?, + ActivePricing::External(inner) => inner.present_value(pool_id, maturity_date)?, }; self.write_down(value) @@ -290,12 +288,12 @@ impl ActiveLoan { Rates: RateCollection, Prices: DataCollection>, { + let maturity_date = self.schedule.maturity.date(); let value = match &self.pricing { ActivePricing::Internal(inner) => { - let maturity_date = self.schedule.maturity.date(); inner.present_value_cached(rates, self.origination_date, maturity_date)? } - ActivePricing::External(inner) => inner.present_value_cached(prices)?, + ActivePricing::External(inner) => inner.present_value_cached(prices, maturity_date)?, }; self.write_down(value) @@ -327,7 +325,7 @@ impl ActiveLoan { BorrowRestrictions::OraclePriceRequired => { match &self.pricing { ActivePricing::Internal(_) => true, - ActivePricing::External(inner) => inner.last_updated(pool_id).is_ok(), + ActivePricing::External(inner) => inner.has_registered_price(pool_id), } } }, @@ -533,31 +531,105 @@ pub struct ActiveLoanInfo { /// Current outstanding interest of this loan pub outstanding_interest: T::Balance, + + /// Current price for external loans + /// - If oracle set, then the price is the one coming from the oracle, + /// - If not set, then the price is a linear accrual using the latest + /// settlement price. + /// See [`ExternalActivePricing::current_price()`] + pub current_price: Option, } impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { type Error = DispatchError; fn try_from((pool_id, active_loan): (T::PoolId, ActiveLoan)) -> Result { - let (outstanding_principal, outstanding_interest) = match &active_loan.pricing { + let present_value = active_loan.present_value(pool_id)?; + + Ok(match &active_loan.pricing { ActivePricing::Internal(inner) => { let principal = active_loan .total_borrowed .ensure_sub(active_loan.total_repaid.principal)?; - (principal, inner.outstanding_interest(principal)?) + Self { + present_value, + outstanding_principal: principal, + outstanding_interest: inner.outstanding_interest(principal)?, + current_price: None, + active_loan, + } + } + ActivePricing::External(inner) => { + let maturity = active_loan.maturity_date(); + + Self { + present_value, + outstanding_principal: inner.outstanding_principal(pool_id, maturity)?, + outstanding_interest: inner.outstanding_interest()?, + current_price: Some(inner.current_price(pool_id, maturity)?), + active_loan, + } } - ActivePricing::External(inner) => ( - inner.outstanding_principal(pool_id)?, - inner.outstanding_interest()?, - ), - }; - - Ok(Self { - present_value: active_loan.present_value(pool_id)?, - outstanding_principal, - outstanding_interest, - active_loan, }) } } + +/// Migration module that contains old loans types. +/// Can be removed once chains contains pallet-loans version v3 +pub(crate) mod v2 { + use cfg_traits::Seconds; + use parity_scale_codec::Decode; + + use crate::{ + entities::pricing::{external::v2::ExternalActivePricing, internal::InternalActivePricing}, + types::{LoanRestrictions, RepaidAmount, RepaymentSchedule}, + AssetOf, Config, + }; + + #[derive(Decode)] + pub enum ActivePricing { + Internal(InternalActivePricing), + External(ExternalActivePricing), + } + + #[derive(Decode)] + pub struct ActiveLoan { + schedule: RepaymentSchedule, + collateral: AssetOf, + restrictions: LoanRestrictions, + borrower: T::AccountId, + write_off_percentage: T::Rate, + origination_date: Seconds, + pricing: ActivePricing, + total_borrowed: T::Balance, + total_repaid: RepaidAmount, + repayments_on_schedule_until: Seconds, + } + + impl ActiveLoan { + pub fn migrate(self) -> crate::entities::loans::ActiveLoan { + crate::entities::loans::ActiveLoan { + schedule: self.schedule, + collateral: self.collateral, + restrictions: self.restrictions, + borrower: self.borrower, + write_off_percentage: self.write_off_percentage, + origination_date: self.origination_date, + pricing: match self.pricing { + ActivePricing::Internal(inner) => { + crate::entities::pricing::ActivePricing::Internal(inner) + } + ActivePricing::External(inner) => { + crate::entities::pricing::ActivePricing::External( + inner.migrate(self.origination_date), + ) + } + }, + total_borrowed: self.total_borrowed, + total_repaid: self.total_repaid, + repayments_on_schedule_until: self.repayments_on_schedule_until, + } + } + } +} diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index 98697a7afc..c79e71cc8e 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -2,6 +2,7 @@ use cfg_traits::{ self, data::{DataCollection, DataRegistry}, interest::InterestRate, + IntoSeconds, Seconds, TimeAsSecs, }; use cfg_types::adjustments::Adjustment; use frame_support::{self, ensure, RuntimeDebug, RuntimeDebugNoBound}; @@ -70,6 +71,7 @@ pub struct ExternalPricing { pub max_borrow_amount: MaxBorrowAmount, /// Reference price used to calculate the interest + /// It refers to the expected asset price. pub notional: T::Balance, /// Maximum variation between the settlement price chosen for @@ -99,6 +101,9 @@ pub struct ExternalActivePricing { /// Settlement price used in the most recent borrow or repay transaction. latest_settlement_price: T::Balance, + + /// When `latest_settlement_price` was updated. + settlement_price_updated: Seconds, } impl ExternalActivePricing { @@ -120,6 +125,7 @@ impl ExternalActivePricing { outstanding_quantity: T::Quantity::zero(), interest: ActiveInterestRate::activate(interest_rate)?, latest_settlement_price: amount.settlement_price, + settlement_price_updated: T::Time::now(), }) } @@ -131,15 +137,42 @@ impl ExternalActivePricing { Ok((self.info, self.interest.deactivate()?)) } - pub fn last_updated(&self, pool_id: T::PoolId) -> Result { - Ok(T::PriceRegistry::get(&self.info.price_id, &pool_id)?.1) + pub fn has_registered_price(&self, pool_id: T::PoolId) -> bool { + T::PriceRegistry::get(&self.info.price_id, &pool_id).is_ok() + } + + pub fn last_updated(&self, pool_id: T::PoolId) -> Seconds { + match T::PriceRegistry::get(&self.info.price_id, &pool_id) { + Ok((_, timestamp)) => timestamp.into_seconds(), + Err(_) => self.settlement_price_updated, + } + } + + fn linear_accrual_price(&self, maturity: Seconds) -> Result { + Ok(cfg_utils::math::y_coord_in_rect( + (self.settlement_price_updated, self.latest_settlement_price), + (maturity, self.info.notional), + T::Time::now(), + )?) } - pub fn outstanding_principal(&self, pool_id: T::PoolId) -> Result { - let price = match T::PriceRegistry::get(&self.info.price_id, &pool_id) { + pub fn current_price( + &self, + pool_id: T::PoolId, + maturity: Seconds, + ) -> Result { + Ok(match T::PriceRegistry::get(&self.info.price_id, &pool_id) { Ok(data) => data.0, - Err(_) => self.latest_settlement_price, - }; + Err(_) => self.linear_accrual_price(maturity)?, + }) + } + + pub fn outstanding_principal( + &self, + pool_id: T::PoolId, + maturity: Seconds, + ) -> Result { + let price = self.current_price(pool_id, maturity)?; Ok(self.outstanding_quantity.ensure_mul_int(price)?) } @@ -152,17 +185,25 @@ impl ExternalActivePricing { Ok(debt.ensure_sub(outstanding_notional)?) } - pub fn present_value(&self, pool_id: T::PoolId) -> Result { - self.outstanding_principal(pool_id) + pub fn present_value( + &self, + pool_id: T::PoolId, + maturity: Seconds, + ) -> Result { + self.outstanding_principal(pool_id, maturity) } - pub fn present_value_cached(&self, cache: &Prices) -> Result + pub fn present_value_cached( + &self, + cache: &Prices, + maturity: Seconds, + ) -> Result where Prices: DataCollection>, { let price = match cache.get(&self.info.price_id) { Ok(data) => data.0, - Err(_) => self.latest_settlement_price, + Err(_) => self.linear_accrual_price(maturity)?, }; Ok(self.outstanding_quantity.ensure_mul_int(price)?) } @@ -243,7 +284,43 @@ impl ExternalActivePricing { self.interest.adjust_debt(interest_adj)?; self.latest_settlement_price = amount_adj.abs().settlement_price; + self.settlement_price_updated = T::Time::now(); Ok(()) } } + +/// Migration module that contains old loans types. +/// Can be removed once chains contains pallet-loans version v3 +pub(crate) mod v2 { + use cfg_traits::Seconds; + use parity_scale_codec::Decode; + + use crate::{ + entities::{interest::ActiveInterestRate, pricing::external::ExternalPricing}, + Config, + }; + + #[derive(Decode)] + pub struct ExternalActivePricing { + info: ExternalPricing, + outstanding_quantity: T::Quantity, + interest: ActiveInterestRate, + latest_settlement_price: T::Balance, + } + + impl ExternalActivePricing { + pub fn migrate( + self, + settlement_price_updated: Seconds, + ) -> crate::entities::pricing::external::ExternalActivePricing { + crate::entities::pricing::external::ExternalActivePricing { + info: self.info, + outstanding_quantity: self.outstanding_quantity, + interest: self.interest, + latest_settlement_price: self.latest_settlement_price, + settlement_price_updated, + } + } + } +} diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index 0802da14e7..b5d3910ab9 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -65,6 +65,8 @@ mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; +mod migrations; + pub use pallet::*; pub use weights::WeightInfo; @@ -115,7 +117,7 @@ pub mod pallet { pub type AssetOf = (::CollectionId, ::ItemId); pub type PriceOf = (::Balance, ::Moment); - const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + const STORAGE_VERSION: StorageVersion = StorageVersion::new(3); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -433,6 +435,13 @@ pub mod pallet { } } + #[pallet::hooks] + impl Hooks> for Pallet { + fn on_runtime_upgrade() -> frame_support::weights::Weight { + migrations::migrate_from_v2_to_v3::() + } + } + #[pallet::call] impl Pallet { /// Creates a new loan against the collateral provided diff --git a/pallets/loans/src/migrations.rs b/pallets/loans/src/migrations.rs new file mode 100644 index 0000000000..6876ade1f2 --- /dev/null +++ b/pallets/loans/src/migrations.rs @@ -0,0 +1,75 @@ +// Copyright 2024 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::PoolNAV; +use frame_support::{ + dispatch::GetStorageVersion, inherent::Vec, log, pallet_prelude::StorageVersion, traits::Get, + weights::Weight, +}; + +use crate::{ActiveLoans, Config, Pallet}; + +mod v2 { + use frame_support::{pallet_prelude::*, storage_alias}; + + use crate::{entities::loans::v2, Config, Pallet}; + + pub type ActiveLoansVec = BoundedVec< + (::LoanId, v2::ActiveLoan), + ::MaxActiveLoansPerPool, + >; + + #[storage_alias] + pub type ActiveLoans = StorageMap< + Pallet, + Blake2_128Concat, + ::PoolId, + ActiveLoansVec, + ValueQuery, + >; +} + +pub fn migrate_from_v2_to_v3() -> Weight { + if Pallet::::on_chain_storage_version() == StorageVersion::new(2) { + log::info!("Loans: Starting migration v2 -> v3"); + + let mut changed_pools = Vec::new(); + ActiveLoans::::translate::, _>(|pool_id, active_loans| { + changed_pools.push(pool_id); + Some( + active_loans + .into_iter() + .map(|(loan_id, active_loan)| (loan_id, active_loan.migrate())) + .collect::>() + .try_into() + .expect("size doest not change, qed"), + ) + }); + + for pool_id in &changed_pools { + match Pallet::::update_nav(*pool_id) { + Ok(_) => log::info!("Loans: updated portfolio for pool_id: {pool_id:?}"), + Err(e) => log::error!("Loans: error updating the portfolio for {pool_id:?}: {e:?}"), + } + } + + Pallet::::current_storage_version().put::>(); + + let count = changed_pools.len() as u64; + log::info!("Loans: Migrated {} pools", count); + T::DbWeight::get().reads_writes(count + 1, count + 1) + } else { + // wrong storage version + log::info!("Loans: Migration did not execute. This probably should be removed"); + T::DbWeight::get().reads_writes(1, 0) + } +} diff --git a/pallets/loans/src/tests/portfolio_valuation.rs b/pallets/loans/src/tests/portfolio_valuation.rs index f944ee8066..af1b548723 100644 --- a/pallets/loans/src/tests/portfolio_valuation.rs +++ b/pallets/loans/src/tests/portfolio_valuation.rs @@ -189,9 +189,13 @@ fn with_unregister_price_id_and_oracle_not_required() { util::borrow_loan(loan_1, PrincipalInput::External(amount.clone())); advance_time(YEAR / 2); + + // This is affected by the linear_accrual_price() computation. + let price_value_after_half_year = PRICE_VALUE + (NOTIONAL - PRICE_VALUE) / 2; + config_mocks(); update_portfolio(); - expected_portfolio(QUANTITY.saturating_mul_int(PRICE_VALUE)); + expected_portfolio(QUANTITY.saturating_mul_int(price_value_after_half_year)); // Suddenty, the oracle set a value MockPrices::mock_collection(|_| {