diff --git a/pallets/loans/docs/types.md b/pallets/loans/docs/types.md index e160145948..bb1f2f30b5 100644 --- a/pallets/loans/docs/types.md +++ b/pallets/loans/docs/types.md @@ -156,7 +156,7 @@ package pricing { price_id: PriceId, max_borrow_quantity: MaxBorrowAmount, notional: Balance, - pool_id: PoolId + max_price_variation: Rate, } ExternalPricing *-l-> MaxBorrowAmount diff --git a/pallets/loans/src/entities/loans.rs b/pallets/loans/src/entities/loans.rs index cdf11dd68a..d6194c91d4 100644 --- a/pallets/loans/src/entities/loans.rs +++ b/pallets/loans/src/entities/loans.rs @@ -281,7 +281,7 @@ impl ActiveLoan { self.write_down(value) } - fn ensure_can_borrow(&self, amount: &PricingAmount) -> DispatchResult { + fn ensure_can_borrow(&self, amount: &PricingAmount, pool_id: T::PoolId) -> DispatchResult { let max_borrow_amount = match &self.pricing { ActivePricing::Internal(inner) => { amount.internal()?; @@ -289,7 +289,7 @@ impl ActiveLoan { } ActivePricing::External(inner) => { let external_amount = amount.external()?; - inner.max_borrow_amount(external_amount)? + inner.max_borrow_amount(external_amount, pool_id)? } }; @@ -317,8 +317,8 @@ impl ActiveLoan { Ok(()) } - pub fn borrow(&mut self, amount: &PricingAmount) -> DispatchResult { - self.ensure_can_borrow(amount)?; + pub fn borrow(&mut self, amount: &PricingAmount, pool_id: T::PoolId) -> DispatchResult { + self.ensure_can_borrow(amount, pool_id)?; self.total_borrowed.ensure_add_assign(amount.balance()?)?; @@ -344,6 +344,7 @@ impl ActiveLoan { fn prepare_repayment( &self, mut amount: RepaidPricingAmount, + pool_id: T::PoolId, ) -> Result, DispatchError> { let (max_repay_principal, outstanding_interest) = match &self.pricing { ActivePricing::Internal(inner) => { @@ -357,7 +358,7 @@ impl ActiveLoan { } ActivePricing::External(inner) => { let external_amount = amount.principal.external()?; - let max_repay_principal = inner.max_repay_principal(external_amount)?; + let max_repay_principal = inner.max_repay_principal(external_amount, pool_id)?; (max_repay_principal, inner.outstanding_interest()?) } @@ -387,8 +388,9 @@ impl ActiveLoan { pub fn repay( &mut self, amount: RepaidPricingAmount, + pool_id: T::PoolId, ) -> Result, DispatchError> { - let amount = self.prepare_repayment(amount)?; + let amount = self.prepare_repayment(amount, pool_id)?; self.total_repaid .ensure_add_assign(&amount.repaid_amount()?)?; @@ -512,32 +514,25 @@ impl TryFrom<(T::PoolId, ActiveLoan)> for ActiveLoanInfo { type Error = DispatchError; fn try_from((pool_id, active_loan): (T::PoolId, ActiveLoan)) -> Result { - let (present_value, outstanding_principal, outstanding_interest) = - match &active_loan.pricing { - ActivePricing::Internal(inner) => { - let principal = active_loan - .total_borrowed - .ensure_sub(active_loan.total_repaid.principal)?; - let maturity_date = active_loan.schedule.maturity.date(); - - ( - inner.present_value(active_loan.origination_date, maturity_date)?, - principal, - inner.outstanding_interest(principal)?, - ) - } - ActivePricing::External(inner) => ( - inner.present_value(pool_id)?, - inner.outstanding_principal(pool_id)?, - inner.outstanding_interest()?, - ), - }; + let (outstanding_principal, outstanding_interest) = 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)?) + } + ActivePricing::External(inner) => ( + inner.outstanding_principal(pool_id)?, + inner.outstanding_interest()?, + ), + }; Ok(Self { - active_loan, - present_value, + present_value: active_loan.present_value(pool_id)?, outstanding_principal, outstanding_interest, + active_loan, }) } } diff --git a/pallets/loans/src/entities/pricing/external.rs b/pallets/loans/src/entities/pricing/external.rs index df8af4af79..5d2c15fb17 100644 --- a/pallets/loans/src/entities/pricing/external.rs +++ b/pallets/loans/src/entities/pricing/external.rs @@ -21,7 +21,12 @@ use crate::{ #[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebugNoBound, MaxEncodedLen)] #[scale_info(skip_type_params(T))] pub struct ExternalAmount { + /// Quantity of different assets identified by the price_id pub quantity: T::Quantity, + + /// Price used to borrow/repay. it must be in the interval + /// [price * (1 - max_price_variation), price * (1 + max_price_variation)], + /// being price the Oracle price. pub settlement_price: T::Balance, } @@ -67,6 +72,11 @@ pub struct ExternalPricing { /// Reference price used to calculate the interest pub notional: T::Balance, + + /// Maximum variation between the settlement price chosen for + /// borrow/repay and the current oracle price. + /// See [`ExternalAmount::settlement_price`]. + pub max_price_variation: T::Rate, } impl ExternalPricing { @@ -148,10 +158,37 @@ impl ExternalActivePricing { Ok(self.outstanding_quantity.ensure_mul_int(price)?) } + fn validate_amount( + &self, + amount: &ExternalAmount, + pool_id: T::PoolId, + ) -> Result<(), DispatchError> { + let price = T::PriceRegistry::get(&self.info.price_id, &pool_id)?.0; + let delta = if amount.settlement_price > price { + amount.settlement_price.ensure_sub(price)? + } else { + price.ensure_sub(amount.settlement_price)? + }; + let variation = + T::Rate::checked_from_rational(delta, price).ok_or(ArithmeticError::Overflow)?; + + // We bypass any price if quantity is zero, + // because it does not take effect in the computation. + ensure!( + variation <= self.info.max_price_variation || amount.quantity.is_zero(), + Error::::SettlementPriceExceedsVariation + ); + + Ok(()) + } + pub fn max_borrow_amount( &self, amount: ExternalAmount, + pool_id: T::PoolId, ) -> Result { + self.validate_amount(&amount, pool_id)?; + match self.info.max_borrow_amount { MaxBorrowAmount::Quantity(quantity) => { let available = quantity.ensure_sub(self.outstanding_quantity)?; @@ -164,7 +201,10 @@ impl ExternalActivePricing { pub fn max_repay_principal( &self, amount: ExternalAmount, + pool_id: T::PoolId, ) -> Result { + self.validate_amount(&amount, pool_id)?; + Ok(self .outstanding_quantity .ensure_mul_int(amount.settlement_price)?) diff --git a/pallets/loans/src/lib.rs b/pallets/loans/src/lib.rs index bdb3e29aa5..637e2ab20d 100644 --- a/pallets/loans/src/lib.rs +++ b/pallets/loans/src/lib.rs @@ -97,7 +97,7 @@ pub mod pallet { }; use frame_system::pallet_prelude::*; use scale_info::TypeInfo; - use sp_arithmetic::FixedPointNumber; + use sp_arithmetic::{FixedPointNumber, PerThing}; use sp_runtime::{ traits::{BadOrigin, EnsureAdd, EnsureAddAssign, EnsureInto, One, Zero}, ArithmeticError, FixedPointOperand, TransactionOutcome, @@ -161,9 +161,12 @@ pub mod pallet { /// Defines the balance type used for math computations type Balance: tokens::Balance + FixedPointOperand; - /// Type to represent different quantities in external pricing. + /// Type to represent different quantities type Quantity: Parameter + Member + FixedPointNumber + TypeInfo + MaxEncodedLen; + /// Defines the perthing type used where values can not overpass 100% + type PerThing: Parameter + Member + PerThing + TypeInfo + MaxEncodedLen; + /// Fetching method for the time of the current block type Time: UnixTime; @@ -365,6 +368,8 @@ pub mod pallet { UnrelatedChangeId, /// Emits when the pricing method is not compatible with the input MismatchedPricingMethod, + /// Emits when settlement price is exceeds the configured variation. + SettlementPriceExceedsVariation, /// Emits when the loan is incorrectly specified and can not be created CreateLoanError(CreateLoanError), /// Emits when the loan can not be borrowed from @@ -473,14 +478,14 @@ pub mod pallet { Self::ensure_loan_borrower(&who, created_loan.borrower())?; let mut active_loan = created_loan.activate(pool_id)?; - active_loan.borrow(&amount)?; + active_loan.borrow(&amount, pool_id)?; Self::insert_active_loan(pool_id, loan_id, active_loan)? } None => { Self::update_active_loan(pool_id, loan_id, |loan| { Self::ensure_loan_borrower(&who, loan.borrower())?; - loan.borrow(&amount) + loan.borrow(&amount, pool_id) })? .1 } @@ -519,7 +524,7 @@ pub mod pallet { let (amount, _count) = Self::update_active_loan(pool_id, loan_id, |loan| { Self::ensure_loan_borrower(&who, loan.borrower())?; - loan.repay(amount.clone()) + loan.repay(amount, pool_id) })?; T::Pool::deposit(pool_id, who, amount.repaid_amount()?.total()?)?; diff --git a/pallets/loans/src/tests/borrow_loan.rs b/pallets/loans/src/tests/borrow_loan.rs index 5bd7d8e98d..d56369115f 100644 --- a/pallets/loans/src/tests/borrow_loan.rs +++ b/pallets/loans/src/tests/borrow_loan.rs @@ -327,19 +327,54 @@ fn with_wrong_quantity_amount_external_pricing() { } #[test] -fn with_correct_amount_external_pricing() { +fn with_incorrect_settlement_price_external_pricing() { new_test_ext().execute_with(|| { let loan_id = util::create_loan(util::base_external_loan()); - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); + // Much higher + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE + PRICE_VALUE + 1); config_mocks(amount.balance().unwrap()); + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PricingAmount::External(amount) + ), + Error::::SettlementPriceExceedsVariation + ); - assert_ok!(Loans::borrow( - RuntimeOrigin::signed(BORROWER), - POOL_A, - loan_id, - PricingAmount::External(amount) - )); + // Higher + let amount = ExternalAmount::new( + QUANTITY, + PRICE_VALUE + (MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE) + 1), + ); + config_mocks(amount.balance().unwrap()); + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PricingAmount::External(amount) + ), + Error::::SettlementPriceExceedsVariation + ); + + // Lower + let amount = ExternalAmount::new( + QUANTITY, + PRICE_VALUE - (MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE) + 1), + ); + config_mocks(amount.balance().unwrap()); + assert_noop!( + Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PricingAmount::External(amount) + ), + Error::::SettlementPriceExceedsVariation + ); }); } @@ -348,7 +383,36 @@ fn with_correct_settlement_price_external_pricing() { new_test_ext().execute_with(|| { let loan_id = util::create_loan(util::base_external_loan()); - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE * 2 /* Any value */); + // Higher + let amount = ExternalAmount::new( + QUANTITY / 3.into(), + PRICE_VALUE + MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE), + ); + config_mocks(amount.balance().unwrap()); + + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PricingAmount::External(amount) + )); + + // Same + let amount = ExternalAmount::new(QUANTITY / 3.into(), PRICE_VALUE); + config_mocks(amount.balance().unwrap()); + + assert_ok!(Loans::borrow( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + PricingAmount::External(amount) + )); + + // Lower + let amount = ExternalAmount::new( + QUANTITY / 3.into(), + PRICE_VALUE - MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE), + ); config_mocks(amount.balance().unwrap()); assert_ok!(Loans::borrow( diff --git a/pallets/loans/src/tests/mock.rs b/pallets/loans/src/tests/mock.rs index b2d535cb6a..594c1b86ce 100644 --- a/pallets/loans/src/tests/mock.rs +++ b/pallets/loans/src/tests/mock.rs @@ -25,7 +25,7 @@ use frame_support::traits::{ }; use frame_system::{EnsureRoot, EnsureSigned}; use scale_info::TypeInfo; -use sp_arithmetic::fixed_point::FixedU64; +use sp_arithmetic::{fixed_point::FixedU64, Perbill}; use sp_core::H256; use sp_runtime::{ testing::Header, @@ -70,8 +70,9 @@ pub const REGISTER_PRICE_ID: PriceId = 42; pub const UNREGISTER_PRICE_ID: PriceId = 88; pub const PRICE_VALUE: Balance = 998; pub const NOTIONAL: Balance = 1000; -pub const QUANTITY: Quantity = Quantity::from_rational(20, 1); +pub const QUANTITY: Quantity = Quantity::from_rational(12, 1); pub const CHANGE_ID: ChangeId = H256::repeat_byte(0x42); +pub const MAX_PRICE_VARIATION: Rate = Rate::from_rational(1, 100); type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; type Block = frame_system::mocking::MockBlock; @@ -230,6 +231,7 @@ impl pallet_loans::Config for Runtime { type MaxActiveLoansPerPool = MaxActiveLoansPerPool; type MaxWriteOffPolicySize = MaxWriteOffPolicySize; type NonFungible = Uniques; + type PerThing = Perbill; type Permissions = MockPermissions; type Pool = MockPools; type PoolId = PoolId; diff --git a/pallets/loans/src/tests/repay_loan.rs b/pallets/loans/src/tests/repay_loan.rs index 545992a8d1..d4a4001aa5 100644 --- a/pallets/loans/src/tests/repay_loan.rs +++ b/pallets/loans/src/tests/repay_loan.rs @@ -509,7 +509,7 @@ fn twice_external_with_elapsed_time() { } #[test] -fn outstanding_debt_rate_no_increase_if_fully_repaid() { +fn current_debt_rate_no_increase_if_fully_repaid() { new_test_ext().execute_with(|| { let loan_id = util::create_loan(LoanInfo { pricing: Pricing::Internal(InternalPricing { @@ -540,30 +540,6 @@ fn outstanding_debt_rate_no_increase_if_fully_repaid() { }); } -#[test] -fn external_pricing_remains_the_same() { - new_test_ext().execute_with(|| { - let loan_id = util::create_loan(util::base_external_loan()); - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); - util::borrow_loan(loan_id, PricingAmount::External(amount)); - - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); - config_mocks(amount.balance().unwrap()); - assert_ok!(Loans::repay( - RuntimeOrigin::signed(BORROWER), - POOL_A, - loan_id, - RepaidPricingAmount { - principal: PricingAmount::External(amount), - interest: 0, - unscheduled: 0, - }, - )); - - assert_eq!(0, util::current_loan_debt(loan_id)); - }); -} - #[test] fn external_pricing_goes_up() { new_test_ext().execute_with(|| { @@ -571,7 +547,7 @@ fn external_pricing_goes_up() { let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); util::borrow_loan(loan_id, PricingAmount::External(amount)); - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE * 2); config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE * 2); assert_ok!(Loans::repay( RuntimeOrigin::signed(BORROWER), @@ -595,7 +571,7 @@ fn external_pricing_goes_down() { let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); util::borrow_loan(loan_id, PricingAmount::External(amount)); - let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE / 2); config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE / 2); assert_ok!(Loans::repay( RuntimeOrigin::signed(BORROWER), @@ -688,3 +664,128 @@ fn with_unscheduled_repayment_external() { ); }); } + +#[test] +fn with_incorrect_settlement_price_external_pricing() { + new_test_ext().execute_with(|| { + let loan_id = util::create_loan(util::base_external_loan()); + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); + util::borrow_loan(loan_id, PricingAmount::External(amount)); + + // Much higher + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE + PRICE_VALUE + 1); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_noop!( + Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + ), + Error::::SettlementPriceExceedsVariation + ); + + // Higher + let amount = ExternalAmount::new( + QUANTITY, + PRICE_VALUE + (MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE) + 1), + ); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_noop!( + Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + ), + Error::::SettlementPriceExceedsVariation + ); + + // Lower + let amount = ExternalAmount::new( + QUANTITY, + PRICE_VALUE - (MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE) + 1), + ); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_noop!( + Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + ), + Error::::SettlementPriceExceedsVariation + ); + }); +} + +#[test] +fn with_correct_settlement_price_external_pricing() { + new_test_ext().execute_with(|| { + let loan_id = util::create_loan(util::base_external_loan()); + let amount = ExternalAmount::new(QUANTITY, PRICE_VALUE); + util::borrow_loan(loan_id, PricingAmount::External(amount)); + + // Higher + let amount = ExternalAmount::new( + QUANTITY / 3.into(), + PRICE_VALUE + MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE), + ); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_ok!(Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + )); + + // Same + let amount = ExternalAmount::new(QUANTITY / 3.into(), PRICE_VALUE); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_ok!(Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + )); + + // Lower + let amount = ExternalAmount::new( + QUANTITY / 3.into(), + PRICE_VALUE - MAX_PRICE_VARIATION.saturating_mul_int(PRICE_VALUE), + ); + config_mocks_with_price(amount.balance().unwrap(), PRICE_VALUE); + assert_ok!(Loans::repay( + RuntimeOrigin::signed(BORROWER), + POOL_A, + loan_id, + RepaidPricingAmount { + principal: PricingAmount::External(amount), + interest: 0, + unscheduled: 0, + }, + )); + + assert_eq!(0, util::current_loan_debt(loan_id)); + }); +} diff --git a/pallets/loans/src/tests/util.rs b/pallets/loans/src/tests/util.rs index 9d266f78a7..06b57591be 100644 --- a/pallets/loans/src/tests/util.rs +++ b/pallets/loans/src/tests/util.rs @@ -97,6 +97,7 @@ pub fn base_external_pricing() -> ExternalPricing { price_id: REGISTER_PRICE_ID, max_borrow_amount: ExtMaxBorrowAmount::Quantity(QUANTITY), notional: NOTIONAL, + max_price_variation: MAX_PRICE_VARIATION, } } diff --git a/runtime/altair/src/lib.rs b/runtime/altair/src/lib.rs index ccb51da7a1..c1b7b1c522 100644 --- a/runtime/altair/src/lib.rs +++ b/runtime/altair/src/lib.rs @@ -88,7 +88,7 @@ use sp_runtime::{ Dispatchable, PostDispatchInfoOf, UniqueSaturatedInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, - ApplyExtrinsicResult, DispatchError, DispatchResult, FixedI128, Perbill, Permill, + ApplyExtrinsicResult, DispatchError, DispatchResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_std::{marker::PhantomData, prelude::*}; #[cfg(any(feature = "std", test))] @@ -1399,6 +1399,7 @@ impl pallet_loans::Config for Runtime { type MaxActiveLoansPerPool = MaxActiveLoansPerPool; type MaxWriteOffPolicySize = MaxWriteOffPolicySize; type NonFungible = Uniques; + type PerThing = Perquintill; type Permissions = Permissions; type Pool = PoolSystem; type PoolId = PoolId; diff --git a/runtime/centrifuge/src/lib.rs b/runtime/centrifuge/src/lib.rs index 3d7f1fdd13..f9f051c57b 100644 --- a/runtime/centrifuge/src/lib.rs +++ b/runtime/centrifuge/src/lib.rs @@ -92,7 +92,7 @@ use sp_runtime::{ transaction_validity::{ InvalidTransaction, TransactionSource, TransactionValidity, TransactionValidityError, }, - ApplyExtrinsicResult, FixedI128, Perbill, Permill, + ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_std::prelude::*; #[cfg(any(feature = "std", test))] @@ -1650,6 +1650,7 @@ impl pallet_loans::Config for Runtime { type MaxActiveLoansPerPool = MaxActiveLoansPerPool; type MaxWriteOffPolicySize = MaxWriteOffPolicySize; type NonFungible = Uniques; + type PerThing = Perquintill; type Permissions = Permissions; type Pool = PoolSystem; type PoolId = PoolId; diff --git a/runtime/development/src/lib.rs b/runtime/development/src/lib.rs index 3d2af30ac6..878b047e99 100644 --- a/runtime/development/src/lib.rs +++ b/runtime/development/src/lib.rs @@ -106,7 +106,7 @@ use sp_runtime::{ Dispatchable, PostDispatchInfoOf, UniqueSaturatedInto, Zero, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, - ApplyExtrinsicResult, FixedI128, Perbill, Permill, + ApplyExtrinsicResult, FixedI128, Perbill, Permill, Perquintill, }; use sp_std::prelude::*; #[cfg(any(feature = "std", test))] @@ -1382,6 +1382,7 @@ impl pallet_loans::Config for Runtime { type MaxActiveLoansPerPool = MaxActiveLoansPerPool; type MaxWriteOffPolicySize = MaxWriteOffPolicySize; type NonFungible = Uniques; + type PerThing = Perquintill; type Permissions = Permissions; type Pool = PoolSystem; type PoolId = PoolId;