From 2b4f94fd09924fca27e97ce3f768e748be1d4aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Enrique=20Mu=C3=B1oz=20Mart=C3=ADn?= Date: Fri, 7 Jun 2024 12:46:53 +0200 Subject: [PATCH] Loans: cashflows simple version (#1867) * updated from #1408 * add testing * organize and polish cashflow module * add Runtime API * update types.md * make it works without chronoutils * minor changes * add borrow check * fix legacy increase_debt test * add loan cashflow tests * compute principal and interest * correct validation * add CashflowPayment type * add variant error * fix interest computation when months are partial * remove Rate usage and use weight * fix start date for cashflows * rename api name * fix benchmarks * taplo fmt * using a lower discount rate to simply benchmarking * rewrite doc line * interest computed at maturity * remove borrow support * fix compilation * None to OnceAtMaturity variant * compilation fixes * Loans: multi cashflows fix external loan (#1864) * correct principal/interest for both kind of loans * support for external prices * add external test * simplify implementation * start from repayments --- Cargo.lock | 1 + Cargo.toml | 1 + pallets/loans/Cargo.toml | 1 + pallets/loans/docs/types.md | 38 +-- pallets/loans/src/benchmarking.rs | 17 +- pallets/loans/src/entities/changes.rs | 5 +- pallets/loans/src/entities/loans.rs | 78 +++--- pallets/loans/src/entities/pricing.rs | 18 +- .../loans/src/entities/pricing/external.rs | 16 +- pallets/loans/src/lib.rs | 16 +- pallets/loans/src/tests/borrow_loan.rs | 101 +++++-- pallets/loans/src/tests/create_loan.rs | 2 +- pallets/loans/src/tests/mod.rs | 15 +- pallets/loans/src/tests/mutate_loan.rs | 5 +- pallets/loans/src/tests/repay_loan.rs | 13 +- pallets/loans/src/tests/util.rs | 28 +- pallets/loans/src/types/cashflow.rs | 254 ++++++++++++++++++ pallets/loans/src/types/mod.rs | 90 +------ runtime/altair/src/lib.rs | 8 +- runtime/centrifuge/src/lib.rs | 7 +- runtime/common/src/apis/loans.rs | 6 +- runtime/development/src/lib.rs | 7 +- .../src/generic/cases/loans.rs | 9 +- .../integration-tests/src/generic/config.rs | 2 +- 24 files changed, 524 insertions(+), 214 deletions(-) create mode 100644 pallets/loans/src/types/cashflow.rs diff --git a/Cargo.lock b/Cargo.lock index 5174d7d93f..5931ec30ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8045,6 +8045,7 @@ dependencies = [ "cfg-traits", "cfg-types", "cfg-utils", + "chrono", "frame-benchmarking", "frame-support", "frame-system", diff --git a/Cargo.toml b/Cargo.toml index bd12d51f49..19c481087d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ rev_slice = { version = "0.1.5", default-features = false } impl-trait-for-tuples = "0.2.1" num-traits = { version = "0.2", default-features = false } num_enum = { version = "0.5.3", default-features = false } +chrono = { version = "0.4", default-features = false } # Cumulus cumulus-pallet-aura-ext = { git = "https://github.com/paritytech/polkadot-sdk", default-features = false, branch = "release-polkadot-v1.1.0" } diff --git a/pallets/loans/Cargo.toml b/pallets/loans/Cargo.toml index 5934cd764e..3ad612a42f 100644 --- a/pallets/loans/Cargo.toml +++ b/pallets/loans/Cargo.toml @@ -43,6 +43,7 @@ pallet-timestamp = { workspace = true, default-features = true } pallet-uniques = { workspace = true, default-features = true } cfg-mocks = { workspace = true, default-features = true } +chrono = { workspace = true } [features] default = ["std"] diff --git a/pallets/loans/docs/types.md b/pallets/loans/docs/types.md index 3ec16a509c..745effae9d 100644 --- a/pallets/loans/docs/types.md +++ b/pallets/loans/docs/types.md @@ -4,28 +4,30 @@ set namespaceSeparator :: hide methods -enum Maturity { - Fixed::date: Seconds - Fixed::extension: Seconds -} +package cashflow { + enum Maturity { + Fixed::date: Seconds + Fixed::extension: Seconds + } -enum InterestPayments { - None -} + enum InterestPayments { + OnceAtMaturity + } -enum PayDownSchedule { - None -} + enum PayDownSchedule { + None + } -class RepaymentSchedule { - maturity: Maturity - interest_payments: InterestPayments - pay_down_schedule: PayDownSchedule -} + class RepaymentSchedule { + maturity: Maturity + interest_payments: InterestPayments + pay_down_schedule: PayDownSchedule + } -RepaymentSchedule *--> Maturity -RepaymentSchedule *---> PayDownSchedule -RepaymentSchedule *----> InterestPayments + RepaymentSchedule *--> Maturity + RepaymentSchedule *--> PayDownSchedule + RepaymentSchedule *--> InterestPayments +} enum BorrowRestrictions { NoWrittenOff diff --git a/pallets/loans/src/benchmarking.rs b/pallets/loans/src/benchmarking.rs index e2d8c185de..b893fef06c 100644 --- a/pallets/loans/src/benchmarking.rs +++ b/pallets/loans/src/benchmarking.rs @@ -16,7 +16,7 @@ use cfg_traits::{ benchmarking::FundedPoolBenchmarkHelper, changes::ChangeGuard, interest::{CompoundingSchedule, InterestAccrual, InterestRate}, - Permissions, PoolWriteOffPolicyMutate, Seconds, TimeAsSecs, ValueProvider, + Permissions, PoolWriteOffPolicyMutate, TimeAsSecs, ValueProvider, }; use cfg_types::{ adjustments::Adjustment, @@ -40,13 +40,12 @@ use crate::{ }, pallet::*, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowRestrictions, InterestPayments, LoanRestrictions, Maturity, PayDownSchedule, - RepayRestrictions, RepaymentSchedule, + BorrowRestrictions, LoanRestrictions, RepayRestrictions, }, }; -const OFFSET: Seconds = 120; const COLLECION_ID: u16 = 42; const COLLATERAL_VALUE: u128 = 1_000_000; const FUNDS: u128 = 1_000_000_000; @@ -125,10 +124,12 @@ where } fn base_loan(item_id: T::ItemId) -> LoanInfo { + let maturity_offset = 40 * 365 * 24 * 3600; // 40 years + LoanInfo { schedule: RepaymentSchedule { - maturity: Maturity::fixed(T::Time::now() + OFFSET), - interest_payments: InterestPayments::None, + maturity: Maturity::fixed(T::Time::now() + maturity_offset), + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, collateral: (COLLECION_ID.into(), item_id), @@ -145,7 +146,7 @@ where probability_of_default: T::Rate::zero(), loss_given_default: T::Rate::zero(), discount_rate: InterestRate::Fixed { - rate_per_year: T::Rate::one(), + rate_per_year: T::Rate::saturating_from_rational(1, 5000), compounding: CompoundingSchedule::Secondly, }, }), @@ -199,7 +200,7 @@ where } fn create_mutation() -> LoanMutation { - LoanMutation::InterestPayments(InterestPayments::None) + LoanMutation::InterestPayments(InterestPayments::OnceAtMaturity) } fn propose_mutation(pool_id: T::PoolId, loan_id: T::LoanId) -> T::Hash { diff --git a/pallets/loans/src/entities/changes.rs b/pallets/loans/src/entities/changes.rs index 1181f79da4..eff6cd3ee4 100644 --- a/pallets/loans/src/entities/changes.rs +++ b/pallets/loans/src/entities/changes.rs @@ -7,8 +7,9 @@ use crate::{ entities::input::{PrincipalInput, RepaidInput}, pallet::Config, types::{ - policy::WriteOffRule, valuation::ValuationMethod, InterestPayments, Maturity, - PayDownSchedule, + cashflow::{InterestPayments, Maturity, PayDownSchedule}, + policy::WriteOffRule, + valuation::ValuationMethod, }, }; diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index 105ecd8ca0..b9cb593740 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -14,7 +14,7 @@ use sp_runtime::{ }, DispatchError, }; -use sp_std::collections::btree_map::BTreeMap; +use sp_std::{collections::btree_map::BTreeMap, vec::Vec}; use crate::{ entities::{ @@ -27,9 +27,10 @@ use crate::{ }, pallet::{AssetOf, Config, Error}, types::{ + cashflow::{CashflowPayment, RepaymentSchedule}, policy::{WriteOffStatus, WriteOffTrigger}, BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanRestrictions, - MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, RepaymentSchedule, + MutationError, RepaidAmount, RepayLoanError, RepayRestrictions, }, PriceOf, }; @@ -71,7 +72,7 @@ impl LoanInfo { T::InterestAccrual::validate_rate(&self.interest_rate)?; ensure!( - self.schedule.is_valid(now), + self.schedule.is_valid(now)?, Error::::from(CreateLoanError::InvalidRepaymentSchedule) ); @@ -223,6 +224,10 @@ impl ActiveLoan { &self.borrower } + pub fn origination_date(&self) -> Seconds { + self.origination_date + } + pub fn maturity_date(&self) -> Option { self.schedule.maturity.date() } @@ -238,13 +243,28 @@ impl ActiveLoan { } } + pub fn principal(&self) -> Result { + Ok(self + .total_borrowed + .ensure_sub(self.total_repaid.principal)?) + } + + pub fn expected_cashflows(&self) -> Result>, DispatchError> { + self.schedule.generate_cashflows( + self.repayments_on_schedule_until, + self.principal()?, + match &self.pricing { + ActivePricing::Internal(_) => self.principal()?, + ActivePricing::External(inner) => inner.outstanding_notional_principal()?, + }, + self.pricing.interest().rate(), + ) + } + pub fn write_off_status(&self) -> WriteOffStatus { WriteOffStatus { percentage: self.write_off_percentage, - penalty: match &self.pricing { - ActivePricing::Internal(inner) => inner.interest.penalty(), - ActivePricing::External(inner) => inner.interest.penalty(), - }, + penalty: self.pricing.interest().penalty(), } } @@ -364,6 +384,8 @@ impl ActiveLoan { } } + self.repayments_on_schedule_until = T::Time::now(); + Ok(()) } @@ -380,11 +402,8 @@ impl ActiveLoan { ) -> Result, DispatchError> { let (max_repay_principal, outstanding_interest) = match &self.pricing { ActivePricing::Internal(inner) => { - amount.principal.internal()?; - - let principal = self - .total_borrowed - .ensure_sub(self.total_repaid.principal)?; + let _ = amount.principal.internal()?; + let principal = self.principal()?; (principal, inner.outstanding_interest(principal)?) } @@ -438,15 +457,15 @@ impl ActiveLoan { } } + self.repayments_on_schedule_until = T::Time::now(); + Ok(amount) } pub fn write_off(&mut self, new_status: &WriteOffStatus) -> DispatchResult { - let penalty = new_status.penalty; - match &mut self.pricing { - ActivePricing::Internal(inner) => inner.interest.set_penalty(penalty)?, - ActivePricing::External(inner) => inner.interest.set_penalty(penalty)?, - } + self.pricing + .interest_mut() + .set_penalty(new_status.penalty)?; self.write_off_percentage = new_status.percentage; @@ -454,12 +473,10 @@ impl ActiveLoan { } fn ensure_can_close(&self) -> DispatchResult { - let can_close = match &self.pricing { - ActivePricing::Internal(inner) => !inner.interest.has_debt(), - ActivePricing::External(inner) => !inner.interest.has_debt(), - }; - - ensure!(can_close, Error::::from(CloseLoanError::NotFullyRepaid)); + ensure!( + !self.pricing.interest().has_debt(), + Error::::from(CloseLoanError::NotFullyRepaid) + ); Ok(()) } @@ -502,10 +519,7 @@ impl ActiveLoan { .maturity .extends(extension) .map_err(|_| Error::::from(MutationError::MaturityExtendedTooMuch))?, - LoanMutation::InterestRate(rate) => match &mut self.pricing { - ActivePricing::Internal(inner) => inner.interest.set_base_rate(rate)?, - ActivePricing::External(inner) => inner.interest.set_base_rate(rate)?, - }, + LoanMutation::InterestRate(rate) => self.pricing.interest_mut().set_base_rate(rate)?, LoanMutation::InterestPayments(payments) => self.schedule.interest_payments = payments, LoanMutation::PayDownSchedule(schedule) => self.schedule.pay_down_schedule = schedule, LoanMutation::Internal(mutation) => match &mut self.pricing { @@ -521,7 +535,7 @@ impl ActiveLoan { #[cfg(feature = "runtime-benchmarks")] pub fn set_maturity(&mut self, duration: Seconds) { - self.schedule.maturity = crate::types::Maturity::fixed(duration); + self.schedule.maturity = crate::types::cashflow::Maturity::fixed(duration); } } @@ -557,9 +571,7 @@ impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { Ok(match &active_loan.pricing { ActivePricing::Internal(inner) => { - let principal = active_loan - .total_borrowed - .ensure_sub(active_loan.total_repaid.principal)?; + let principal = active_loan.principal()?; Self { present_value, @@ -574,7 +586,7 @@ impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { Self { present_value, - outstanding_principal: inner.outstanding_principal(pool_id, maturity)?, + outstanding_principal: inner.outstanding_priced_principal(pool_id, maturity)?, outstanding_interest: inner.outstanding_interest()?, current_price: Some(inner.current_price(pool_id, maturity)?), active_loan, @@ -594,7 +606,7 @@ pub mod v3 { loans::BlockNumberFor, pricing::external::v3::{ActivePricing, Pricing}, }, - types::{LoanRestrictions, RepaidAmount, RepaymentSchedule}, + types::{cashflow::RepaymentSchedule, LoanRestrictions, RepaidAmount}, AssetOf, Config, }; diff --git a/pallets/loans/src/entities/pricing.rs b/pallets/loans/src/entities/pricing.rs index 557b6b88a8..799feb35b8 100644 --- a/pallets/loans/src/entities/pricing.rs +++ b/pallets/loans/src/entities/pricing.rs @@ -2,7 +2,7 @@ use frame_support::RuntimeDebugNoBound; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use crate::pallet::Config; +use crate::{entities::interest::ActiveInterestRate, pallet::Config}; pub mod external; pub mod internal; @@ -28,3 +28,19 @@ pub enum ActivePricing { /// Internal attributes External(external::ExternalActivePricing), } + +impl ActivePricing { + pub fn interest(&self) -> &ActiveInterestRate { + match self { + Self::Internal(inner) => &inner.interest, + Self::External(inner) => &inner.interest, + } + } + + pub fn interest_mut(&mut self) -> &mut ActiveInterestRate { + match self { + Self::Internal(inner) => &mut inner.interest, + Self::External(inner) => &mut inner.interest, + } + } +} diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index e9652ac518..00d15a7532 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -208,7 +208,13 @@ impl ExternalActivePricing { } } - pub fn outstanding_principal( + pub fn outstanding_notional_principal(&self) -> Result { + Ok(self + .outstanding_quantity + .ensure_mul_int(self.info.notional)?) + } + + pub fn outstanding_priced_principal( &self, pool_id: T::PoolId, maturity: Option, @@ -218,12 +224,8 @@ impl ExternalActivePricing { } pub fn outstanding_interest(&self) -> Result { - let outstanding_notional = self - .outstanding_quantity - .ensure_mul_int(self.info.notional)?; - let debt = self.interest.current_debt()?; - Ok(debt.ensure_sub(outstanding_notional)?) + Ok(debt.ensure_sub(self.outstanding_notional_principal()?)?) } pub fn present_value( @@ -231,7 +233,7 @@ impl ExternalActivePricing { pool_id: T::PoolId, maturity: Option, ) -> Result { - self.outstanding_principal(pool_id, maturity) + self.outstanding_priced_principal(pool_id, maturity) } pub fn present_value_cached( diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index e79832ed7b..f3c3bceae7 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -107,6 +107,7 @@ pub mod pallet { use sp_std::{collections::btree_map::BTreeMap, vec, vec::Vec}; use types::{ self, + cashflow::CashflowPayment, policy::{self, WriteOffRule, WriteOffStatus}, BorrowLoanError, CloseLoanError, CreateLoanError, MutationError, RepayLoanError, WrittenOffError, @@ -1237,7 +1238,7 @@ pub mod pallet { ) -> Result, DispatchError> { ActiveLoans::::get(pool_id) .into_iter() - .map(|(loan_id, loan)| Ok((loan_id, (pool_id, loan).try_into()?))) + .map(|(loan_id, loan)| Ok((loan_id, ActiveLoanInfo::try_from((pool_id, loan))?))) .collect() } @@ -1248,9 +1249,20 @@ pub mod pallet { ActiveLoans::::get(pool_id) .into_iter() .find(|(id, _)| *id == loan_id) - .map(|(_, loan)| (pool_id, loan).try_into()) + .map(|(_, loan)| ActiveLoanInfo::try_from((pool_id, loan))) .transpose() } + + pub fn expected_cashflows( + pool_id: T::PoolId, + loan_id: T::LoanId, + ) -> Result>, DispatchError> { + ActiveLoans::::get(pool_id) + .into_iter() + .find(|(id, _)| *id == loan_id) + .map(|(_, loan)| loan.expected_cashflows()) + .ok_or(Error::::LoanNotActiveOrNotFound)? + } } // TODO: This implementation can be cleaned once #908 be solved diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index c74281bb65..ad3cf70651 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -586,10 +586,6 @@ fn twice_with_elapsed_time() { #[test] fn increase_debt_does_not_withdraw() { new_test_ext().execute_with(|| { - MockPools::mock_withdraw(|_, _, _| { - unreachable!("increase debt must not withdraw funds from the pool"); - }); - let loan = LoanInfo { pricing: Pricing::External(ExternalPricing { max_borrow_amount: ExtMaxBorrowAmount::NoLimit, @@ -601,19 +597,10 @@ fn increase_debt_does_not_withdraw() { let loan_id = util::create_loan(loan); let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); - MockPrices::mock_get(|id, pool_id| { - assert_eq!(*pool_id, POOL_A); - match *id { - REGISTER_PRICE_ID => Ok((PRICE_VALUE, BLOCK_TIME_MS)), - _ => Err(PRICE_ID_NO_FOUND), - } - }); - MockPrices::mock_register_id(|id, pool_id| { - assert_eq!(*pool_id, POOL_A); - match *id { - REGISTER_PRICE_ID => Ok(()), - _ => Err(PRICE_ID_NO_FOUND), - } + + config_mocks(amount.balance().unwrap()); + MockPools::mock_withdraw(|_, _, _| { + unreachable!("increase debt must not withdraw funds from the pool"); }); assert_ok!(Loans::increase_debt( @@ -624,3 +611,83 @@ fn increase_debt_does_not_withdraw() { )); }); } + +mod cashflow { + use super::*; + + #[test] + fn computed_correctly_internal_pricing() { + new_test_ext().execute_with(|| { + let loan_id = util::create_loan(util::base_internal_loan()); + + config_mocks(COLLATERAL_VALUE / 2); + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::Internal(COLLATERAL_VALUE / 2) + )); + + let loan = util::get_loan(loan_id); + + let principal = COLLATERAL_VALUE / 2; + let acc_interest_rate_per_year = checked_pow( + util::default_interest_rate().per_sec().unwrap(), + SECONDS_PER_YEAR as usize, + ) + .unwrap(); + let interest = acc_interest_rate_per_year.saturating_mul_int(principal) - principal; + + assert_eq!( + loan.expected_cashflows() + .unwrap() + .into_iter() + .map(|payment| (payment.when, payment.principal, payment.interest)) + .collect::>(), + vec![(loan.maturity_date().unwrap(), principal, interest)] + ); + }); + } + + #[test] + fn computed_correctly_external_pricing() { + new_test_ext().execute_with(|| { + let loan_id = util::create_loan(util::base_external_loan()); + + let amount = ExternalAmount::new(QUANTITY / 2.into(), PRICE_VALUE); + config_mocks(amount.balance().unwrap()); + + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PrincipalInput::External(amount.clone()) + )); + + let loan = util::get_loan(loan_id); + + let principal = amount.balance().unwrap(); + let acc_interest_rate_per_year = checked_pow( + util::default_interest_rate().per_sec().unwrap(), + SECONDS_PER_YEAR as usize, + ) + .unwrap(); + + let outstanding_notional = util::current_extenal_pricing(loan_id) + .outstanding_notional_principal() + .unwrap(); + + let interest = + acc_interest_rate_per_year.saturating_mul_int(principal) - outstanding_notional; + + assert_eq!( + loan.expected_cashflows() + .unwrap() + .into_iter() + .map(|payment| (payment.when, payment.principal, payment.interest)) + .collect::>(), + vec![(loan.maturity_date().unwrap(), principal, interest)] + ); + }); + } +} diff --git a/pallets/loans/src/tests/create_loan.rs b/pallets/loans/src/tests/create_loan.rs index d74a7af815..37b87acdf9 100644 --- a/pallets/loans/src/tests/create_loan.rs +++ b/pallets/loans/src/tests/create_loan.rs @@ -92,7 +92,7 @@ fn with_wrong_schedule() { let loan = LoanInfo { schedule: RepaymentSchedule { maturity: Maturity::fixed(now().as_secs()), - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, ..util::base_internal_loan() diff --git a/pallets/loans/src/tests/mod.rs b/pallets/loans/src/tests/mod.rs index 13605dde37..01ebc3b8ae 100644 --- a/pallets/loans/src/tests/mod.rs +++ b/pallets/loans/src/tests/mod.rs @@ -1,12 +1,12 @@ use std::time::Duration; use cfg_mocks::pallet_mock_data::util::MockDataCollection; -use cfg_primitives::SECONDS_PER_DAY; +use cfg_primitives::{SECONDS_PER_DAY, SECONDS_PER_YEAR}; use cfg_traits::interest::{CompoundingSchedule, InterestRate}; use cfg_types::permissions::{PermissionScope, PoolRole, Role}; use frame_support::{assert_noop, assert_ok, storage::bounded_vec::BoundedVec}; use sp_runtime::{ - traits::{BadOrigin, One}, + traits::{checked_pow, BadOrigin, One}, DispatchError, FixedPointNumber, }; @@ -16,18 +16,21 @@ use super::{ input::{PrincipalInput, RepaidInput}, loans::{ActiveLoan, ActiveLoanInfo, LoanInfo}, pricing::{ - external::{ExternalAmount, ExternalPricing, MaxBorrowAmount as ExtMaxBorrowAmount}, + external::{ + ExternalActivePricing, ExternalAmount, ExternalPricing, + MaxBorrowAmount as ExtMaxBorrowAmount, + }, internal::{InternalPricing, MaxBorrowAmount as IntMaxBorrowAmount}, ActivePricing, Pricing, }, }, pallet::{ActiveLoans, CreatedLoan, Error, Event, LastLoanId, PortfolioValuation}, types::{ + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, policy::{WriteOffRule, WriteOffStatus, WriteOffTrigger}, valuation::{DiscountedCashFlow, ValuationMethod}, - BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, InterestPayments, - LoanRestrictions, Maturity, MutationError, PayDownSchedule, RepayLoanError, - RepayRestrictions, RepaymentSchedule, WrittenOffError, + BorrowLoanError, BorrowRestrictions, CloseLoanError, CreateLoanError, LoanRestrictions, + MutationError, RepayLoanError, RepayRestrictions, WrittenOffError, }, }; diff --git a/pallets/loans/src/tests/mutate_loan.rs b/pallets/loans/src/tests/mutate_loan.rs index b85964c263..7f4d5f2e83 100644 --- a/pallets/loans/src/tests/mutate_loan.rs +++ b/pallets/loans/src/tests/mutate_loan.rs @@ -1,6 +1,7 @@ use super::*; -const DEFAULT_MUTATION: LoanMutation = LoanMutation::InterestPayments(InterestPayments::None); +const DEFAULT_MUTATION: LoanMutation = + LoanMutation::InterestPayments(InterestPayments::OnceAtMaturity); fn config_mocks(loan_id: LoanId, loan_mutation: &LoanMutation) { MockPermissions::mock_has(|scope, who, role| { @@ -213,7 +214,7 @@ fn with_successful_mutation_application() { date: (now() + YEAR).as_secs(), extension: YEAR.as_secs(), }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: InterestRate::Fixed { diff --git a/pallets/loans/src/tests/repay_loan.rs b/pallets/loans/src/tests/repay_loan.rs index 7772b14849..5177a0dcdf 100644 --- a/pallets/loans/src/tests/repay_loan.rs +++ b/pallets/loans/src/tests/repay_loan.rs @@ -982,10 +982,6 @@ fn with_external_pricing() { #[test] fn decrease_debt_does_not_deposit() { new_test_ext().execute_with(|| { - MockPools::mock_deposit(|_, _, _| { - unreachable!("increase debt must not withdraw funds from the pool"); - }); - let loan = LoanInfo { pricing: Pricing::External(ExternalPricing { max_borrow_amount: ExtMaxBorrowAmount::NoLimit, @@ -999,12 +995,9 @@ fn decrease_debt_does_not_deposit() { let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); util::borrow_loan(loan_id, PrincipalInput::External(amount.clone())); - MockPrices::mock_get(move |id, pool_id| { - assert_eq!(*pool_id, POOL_A); - match *id { - REGISTER_PRICE_ID => Ok((PRICE_VALUE, BLOCK_TIME_MS)), - _ => Err(PRICE_ID_NO_FOUND), - } + config_mocks(amount.balance().unwrap()); + MockPools::mock_deposit(|_, _, _| { + unreachable!("decrease debt must not withdraw funds from the pool"); }); assert_ok!(Loans::decrease_debt( diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index c12c180bdc..4bd83467c6 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -27,6 +27,13 @@ pub fn current_loan_debt(loan_id: LoanId) -> Balance { } } +pub fn current_extenal_pricing(loan_id: LoanId) -> ExternalActivePricing { + match get_loan(loan_id).pricing() { + ActivePricing::Internal(_) => panic!("expected external pricing"), + ActivePricing::External(pricing) => pricing.clone(), + } +} + pub fn borrower(loan_id: LoanId) -> AccountId { match CreatedLoan::::get(POOL_A, loan_id) { Some(created_loan) => *created_loan.borrower(), @@ -103,6 +110,13 @@ pub fn dcf_internal_loan() -> LoanInfo { } } +pub fn default_interest_rate() -> InterestRate { + InterestRate::Fixed { + rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), + compounding: CompoundingSchedule::Secondly, + } +} + pub fn base_internal_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { @@ -110,13 +124,10 @@ pub fn base_internal_loan() -> LoanInfo { date: (now() + YEAR).as_secs(), extension: (YEAR / 2).as_secs(), }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, - interest_rate: InterestRate::Fixed { - rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), - compounding: CompoundingSchedule::Secondly, - }, + interest_rate: default_interest_rate(), collateral: ASSET_AA, pricing: Pricing::Internal(base_internal_pricing()), restrictions: LoanRestrictions { @@ -140,13 +151,10 @@ pub fn base_external_loan() -> LoanInfo { LoanInfo { schedule: RepaymentSchedule { maturity: Maturity::fixed((now() + YEAR).as_secs()), - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, - interest_rate: InterestRate::Fixed { - rate_per_year: Rate::from_float(DEFAULT_INTEREST_RATE), - compounding: CompoundingSchedule::Secondly, - }, + interest_rate: default_interest_rate(), collateral: ASSET_AA, pricing: Pricing::External(base_external_pricing()), restrictions: LoanRestrictions { diff --git a/pallets/loans/src/types/cashflow.rs b/pallets/loans/src/types/cashflow.rs new file mode 100644 index 0000000000..4b5021a5c3 --- /dev/null +++ b/pallets/loans/src/types/cashflow.rs @@ -0,0 +1,254 @@ +// 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_traits::{interest::InterestRate, Seconds}; +use frame_support::pallet_prelude::RuntimeDebug; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::{ + traits::{ + ensure_pow, EnsureAdd, EnsureAddAssign, EnsureFixedPointNumber, EnsureInto, EnsureSub, + EnsureSubAssign, + }, + DispatchError, FixedPointNumber, FixedPointOperand, FixedU128, +}; +use sp_std::{vec, vec::Vec}; + +/// Specify the expected repayments date +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum Maturity { + /// Fixed point in time, in secs + Fixed { + /// Secs when maturity ends + date: Seconds, + /// Extension in secs, without special permissions + extension: Seconds, + }, + /// No Maturity date + None, +} + +impl Maturity { + pub fn fixed(date: Seconds) -> Self { + Self::Fixed { date, extension: 0 } + } + + pub fn date(&self) -> Option { + match self { + Maturity::Fixed { date, .. } => Some(*date), + Maturity::None => None, + } + } + + pub fn is_valid(&self, now: Seconds) -> bool { + match self { + Maturity::Fixed { date, .. } => *date > now, + Maturity::None => true, + } + } + + pub fn extends(&mut self, value: Seconds) -> Result<(), DispatchError> { + match self { + Maturity::Fixed { date, extension } => { + date.ensure_add_assign(value)?; + extension.ensure_sub_assign(value)?; + Ok(()) + } + Maturity::None => Err(DispatchError::Other( + "No maturity date that could be extended.", + )), + } + } +} + +/// Interest payment periods +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum InterestPayments { + /// All interest is expected to be paid at the maturity date + OnceAtMaturity, +} + +/// Specify the paydown schedules of the loan +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub enum PayDownSchedule { + /// No restrictions on how the paydown should be done. + None, +} + +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub struct CashflowPayment { + pub when: Seconds, + pub principal: Balance, + pub interest: Balance, +} + +/// Specify the repayment schedule of the loan +#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] +pub struct RepaymentSchedule { + /// Expected repayments date for remaining debt + pub maturity: Maturity, + + /// Period at which interest is paid + pub interest_payments: InterestPayments, + + /// How much of the initially borrowed amount is paid back during interest + /// payments + pub pay_down_schedule: PayDownSchedule, +} + +impl RepaymentSchedule { + pub fn is_valid(&self, now: Seconds) -> Result { + let valid = match self.interest_payments { + InterestPayments::OnceAtMaturity => true, + }; + + Ok(valid && self.maturity.is_valid(now)) + } + + pub fn generate_cashflows( + &self, + origination_date: Seconds, + principal: Balance, + principal_base: Balance, + interest_rate: &InterestRate, + ) -> Result>, DispatchError> + where + Balance: FixedPointOperand + EnsureAdd + EnsureSub, + Rate: FixedPointNumber, + { + let Some(maturity) = self.maturity.date() else { + return Ok(Vec::new()); + }; + + let timeflow = match &self.interest_payments { + InterestPayments::OnceAtMaturity => vec![(maturity, 1)], + }; + + let total_weight = timeflow + .iter() + .map(|(_, weight)| weight) + .try_fold(0, |a, b| a.ensure_add(*b))?; + + let lifetime = maturity.ensure_sub(origination_date)?.ensure_into()?; + let interest_rate_per_lifetime = ensure_pow(interest_rate.per_sec()?, lifetime)?; + let interest_at_maturity = interest_rate_per_lifetime + .ensure_mul_int(principal)? + .ensure_sub(principal_base)?; + + timeflow + .into_iter() + .map(|(date, weight)| { + let proportion = FixedU128::ensure_from_rational(weight, total_weight)?; + let principal = proportion.ensure_mul_int(principal)?; + let interest = proportion.ensure_mul_int(interest_at_maturity)?; + + Ok(CashflowPayment { + when: date, + principal, + interest, + }) + }) + .collect() + } + + pub fn expected_payment( + &self, + origination_date: Seconds, + principal: Balance, + principal_base: Balance, + interest_rate: &InterestRate, + until: Seconds, + ) -> Result + where + Balance: FixedPointOperand + EnsureAdd + EnsureSub, + Rate: FixedPointNumber, + { + let cashflow = + self.generate_cashflows(origination_date, principal, principal_base, interest_rate)?; + + let total_amount = cashflow + .into_iter() + .take_while(|payment| payment.when < until) + .map(|payment| payment.principal.ensure_add(payment.interest)) + .try_fold(Balance::zero(), |a, b| a.ensure_add(b?))?; + + Ok(total_amount) + } +} + +#[cfg(test)] +pub mod tests { + use cfg_traits::interest::CompoundingSchedule; + use chrono::NaiveDate; + + use super::*; + + pub type Rate = sp_arithmetic::fixed_point::FixedU128; + + fn from_ymd(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + pub fn secs_from_ymdhms( + year: i32, + month: u32, + day: u32, + hour: u32, + min: u32, + sec: u32, + ) -> Seconds { + from_ymd(year, month, day) + .and_hms_opt(hour, min, sec) + .unwrap() + .and_utc() + .timestamp() as Seconds + } + + pub fn last_secs_from_ymd(year: i32, month: u32, day: u32) -> Seconds { + secs_from_ymdhms(year, month, day, 23, 59, 59) + } + + mod once_at_maturity { + use super::*; + + #[test] + fn correct_amounts() { + // To understand the expected interest amounts: + // A rate per year of 0.12 means each month you nearly pay with a rate of 0.01. + // 0.01 of the total principal is 25000 * 0.01 = 250 each month. + // A minor extra amount comes from the secondly compounding interest during 2.5 + // months. + assert_eq!( + RepaymentSchedule { + maturity: Maturity::fixed(last_secs_from_ymd(2022, 7, 1)), + interest_payments: InterestPayments::OnceAtMaturity, + pay_down_schedule: PayDownSchedule::None, + } + .generate_cashflows( + last_secs_from_ymd(2022, 4, 16), + 25000u128, /* principal */ + 25000u128, /* principal as base */ + &InterestRate::Fixed { + rate_per_year: Rate::from_float(0.12), + compounding: CompoundingSchedule::Secondly, + } + ) + .unwrap() + .into_iter() + .map(|payment| (payment.principal, payment.interest)) + .collect::>(), + vec![(25000, 632)] + ) + } + } +} diff --git a/pallets/loans/src/types/mod.rs b/pallets/loans/src/types/mod.rs index 333b8ae0c9..2d279fb9e9 100644 --- a/pallets/loans/src/types/mod.rs +++ b/pallets/loans/src/types/mod.rs @@ -13,15 +13,12 @@ //! Contains base types without Config references -use cfg_traits::Seconds; use frame_support::{pallet_prelude::RuntimeDebug, PalletError}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; -use sp_runtime::{ - traits::{EnsureAdd, EnsureAddAssign, EnsureSubAssign}, - ArithmeticError, DispatchError, -}; +use sp_runtime::{traits::EnsureAdd, ArithmeticError}; +pub mod cashflow; pub mod policy; pub mod valuation; @@ -47,6 +44,8 @@ pub enum BorrowLoanError { Restriction, /// Emits when maturity has passed and borrower tried to borrow more MaturityDatePassed, + /// Emits when the cashflow payment is overdue + PaymentOverdue, } /// Error related to loan borrowing @@ -85,87 +84,6 @@ pub enum MutationError { MaturityExtendedTooMuch, } -/// Specify the expected repayments date -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum Maturity { - /// Fixed point in time, in secs - Fixed { - /// Secs when maturity ends - date: Seconds, - /// Extension in secs, without special permissions - extension: Seconds, - }, - /// No Maturity date - None, -} - -impl Maturity { - pub fn fixed(date: Seconds) -> Self { - Self::Fixed { date, extension: 0 } - } - - pub fn date(&self) -> Option { - match self { - Maturity::Fixed { date, .. } => Some(*date), - Maturity::None => None, - } - } - - pub fn is_valid(&self, now: Seconds) -> bool { - match self { - Maturity::Fixed { date, .. } => *date > now, - Maturity::None => true, - } - } - - pub fn extends(&mut self, value: Seconds) -> Result<(), DispatchError> { - match self { - Maturity::Fixed { date, extension } => { - date.ensure_add_assign(value)?; - extension.ensure_sub_assign(value).map_err(Into::into) - } - Maturity::None => Err(DispatchError::Other( - "No maturity date that could be extended.", - )), - } - } -} - -/// Interest payment periods -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum InterestPayments { - /// All interest is expected to be paid at the maturity date - None, -} - -/// Specify the paydown schedules of the loan -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub enum PayDownSchedule { - /// The entire borrowed amount is expected to be paid back at the maturity - /// date - None, -} - -/// Specify the repayment schedule of the loan -#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebug, MaxEncodedLen)] -pub struct RepaymentSchedule { - /// Expected repayments date for remaining debt - pub maturity: Maturity, - - /// Period at which interest is paid - pub interest_payments: InterestPayments, - - /// How much of the initially borrowed amount is paid back during interest - /// payments - pub pay_down_schedule: PayDownSchedule, -} - -impl RepaymentSchedule { - pub fn is_valid(&self, now: Seconds) -> bool { - self.maturity.is_valid(now) - } -} - /// Specify how offer a loan can be borrowed #[derive(Encode, Decode, Clone, PartialEq, Eq, RuntimeDebug, TypeInfo, MaxEncodedLen)] pub enum BorrowRestrictions { diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index 98f08d9eda..60459973f5 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -77,6 +77,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -121,7 +122,7 @@ use sp_runtime::{ ApplyExtrinsicResult, DispatchError, DispatchResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2322,8 +2323,11 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } - } + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) + } + } // Investment Runtime APIs impl runtime_common::apis::InvestmentsApi> for Runtime { diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index 33135524d4..9bcc090d2d 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -78,6 +78,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -124,7 +125,7 @@ use sp_runtime::{ ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2370,6 +2371,10 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } + + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) + } } // Investment Runtime APIs diff --git a/runtime/common/src/apis/loans.rs b/runtime/common/src/apis/loans.rs index def6a6759a..82a2e970f6 100644 --- a/runtime/common/src/apis/loans.rs +++ b/runtime/common/src/apis/loans.rs @@ -11,6 +11,7 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. +use pallet_loans::types::cashflow::CashflowPayment; use parity_scale_codec::Codec; use sp_api::decl_runtime_apis; use sp_runtime::DispatchError; @@ -18,17 +19,18 @@ use sp_std::vec::Vec; decl_runtime_apis! { /// Runtime API for the rewards pallet. - #[api_version(2)] + #[api_version(3)] pub trait LoansApi where PoolId: Codec, LoanId: Codec, Loan: Codec, Balance: Codec, - PriceCollectionInput: Codec + PriceCollectionInput: Codec, { fn portfolio(pool_id: PoolId) -> Vec<(LoanId, Loan)>; fn portfolio_loan(pool_id: PoolId, loan_id: LoanId) -> Option; fn portfolio_valuation(pool_id: PoolId, input_prices: PriceCollectionInput) -> Result; + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError>; } } diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 4d2205c972..5427d12d86 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -82,6 +82,7 @@ use pallet_investments::OrderType; use pallet_liquidity_pools::hooks::{ CollectedForeignInvestmentHook, CollectedForeignRedemptionHook, DecreasedForeignInvestOrderHook, }; +use pallet_loans::types::cashflow::CashflowPayment; use pallet_pool_system::{ pool_types::{PoolDetails, ScheduledUpdateDetails}, tranches::{TrancheIndex, TrancheLoc, TrancheSolution}, @@ -128,7 +129,7 @@ use sp_runtime::{ ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_staking::currency_to_vote::U128CurrencyToVote; -use sp_std::{marker::PhantomData, prelude::*}; +use sp_std::{marker::PhantomData, prelude::*, vec::Vec}; use sp_version::RuntimeVersion; use staging_xcm_executor::XcmExecutor; use static_assertions::const_assert; @@ -2409,6 +2410,10 @@ impl_runtime_apis! { ) -> Result { Ok(runtime_common::update_nav_with_input(pool_id, input_prices)?.nav_aum) } + + fn expected_cashflows(pool_id: PoolId, loan_id: LoanId) -> Result>, DispatchError> { + Loans::expected_cashflows(pool_id, loan_id) + } } // Investment Runtime APIs diff --git a/runtime/integration-tests/src/generic/cases/loans.rs b/runtime/integration-tests/src/generic/cases/loans.rs index 00c3cdfe63..dc995d7b6e 100644 --- a/runtime/integration-tests/src/generic/cases/loans.rs +++ b/runtime/integration-tests/src/generic/cases/loans.rs @@ -21,12 +21,13 @@ use pallet_loans::{ }, }, types::{ - valuation::ValuationMethod, BorrowLoanError, BorrowRestrictions, InterestPayments, - LoanRestrictions, Maturity, PayDownSchedule, RepayRestrictions, RepaymentSchedule, + cashflow::{InterestPayments, Maturity, PayDownSchedule, RepaymentSchedule}, + valuation::ValuationMethod, + BorrowLoanError, BorrowRestrictions, LoanRestrictions, RepayRestrictions, }, }; use runtime_common::{ - apis::{runtime_decl_for_loans_api::LoansApiV2, runtime_decl_for_pools_api::PoolsApiV1}, + apis::{runtime_decl_for_loans_api::LoansApiV3, runtime_decl_for_pools_api::PoolsApiV1}, oracle::Feeder, }; use sp_runtime::FixedPointNumber; @@ -133,7 +134,7 @@ mod common { date: now + SECONDS_PER_MINUTE, extension: SECONDS_PER_MINUTE / 2, }, - interest_payments: InterestPayments::None, + interest_payments: InterestPayments::OnceAtMaturity, pay_down_schedule: PayDownSchedule::None, }, interest_rate: InterestRate::Fixed { diff --git a/runtime/integration-tests/src/generic/config.rs b/runtime/integration-tests/src/generic/config.rs index 30ec4ff1b0..ef2f6e568a 100644 --- a/runtime/integration-tests/src/generic/config.rs +++ b/runtime/integration-tests/src/generic/config.rs @@ -287,7 +287,7 @@ pub trait Runtime: /// You can extend this bounds to give extra API support type Api: sp_api::runtime_decl_for_core::CoreV4 + sp_block_builder::runtime_decl_for_block_builder::BlockBuilderV6 - + apis::runtime_decl_for_loans_api::LoansApiV2< + + apis::runtime_decl_for_loans_api::LoansApiV3< Self::BlockExt, PoolId, LoanId,