Skip to content

Commit

Permalink
Loans: add slippage for external pricing (#1487)
Browse files Browse the repository at this point in the history
* implementation

* add tests

* fixed runtime API issue with present_value

* update types diagram

* using Perthing

* update runtimes

* update error name

* rename and better docs

* fix saturating issue

* change Perthing to Rate

* update types.md

Signed-off-by: lemunozm <lemunozm@gmail.com>

---------

Signed-off-by: lemunozm <lemunozm@gmail.com>
  • Loading branch information
lemunozm committed Aug 16, 2023
1 parent badb4a9 commit 0294561
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 75 deletions.
2 changes: 1 addition & 1 deletion pallets/loans/docs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 23 additions & 28 deletions pallets/loans/src/entities/loans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,15 +281,15 @@ impl<T: Config> ActiveLoan<T> {
self.write_down(value)
}

fn ensure_can_borrow(&self, amount: &PricingAmount<T>) -> DispatchResult {
fn ensure_can_borrow(&self, amount: &PricingAmount<T>, pool_id: T::PoolId) -> DispatchResult {
let max_borrow_amount = match &self.pricing {
ActivePricing::Internal(inner) => {
amount.internal()?;
inner.max_borrow_amount(self.total_borrowed)?
}
ActivePricing::External(inner) => {
let external_amount = amount.external()?;
inner.max_borrow_amount(external_amount)?
inner.max_borrow_amount(external_amount, pool_id)?
}
};

Expand Down Expand Up @@ -317,8 +317,8 @@ impl<T: Config> ActiveLoan<T> {
Ok(())
}

pub fn borrow(&mut self, amount: &PricingAmount<T>) -> DispatchResult {
self.ensure_can_borrow(amount)?;
pub fn borrow(&mut self, amount: &PricingAmount<T>, pool_id: T::PoolId) -> DispatchResult {
self.ensure_can_borrow(amount, pool_id)?;

self.total_borrowed.ensure_add_assign(amount.balance()?)?;

Expand All @@ -344,6 +344,7 @@ impl<T: Config> ActiveLoan<T> {
fn prepare_repayment(
&self,
mut amount: RepaidPricingAmount<T>,
pool_id: T::PoolId,
) -> Result<RepaidPricingAmount<T>, DispatchError> {
let (max_repay_principal, outstanding_interest) = match &self.pricing {
ActivePricing::Internal(inner) => {
Expand All @@ -357,7 +358,7 @@ impl<T: Config> ActiveLoan<T> {
}
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()?)
}
Expand Down Expand Up @@ -387,8 +388,9 @@ impl<T: Config> ActiveLoan<T> {
pub fn repay(
&mut self,
amount: RepaidPricingAmount<T>,
pool_id: T::PoolId,
) -> Result<RepaidPricingAmount<T>, DispatchError> {
let amount = self.prepare_repayment(amount)?;
let amount = self.prepare_repayment(amount, pool_id)?;

self.total_repaid
.ensure_add_assign(&amount.repaid_amount()?)?;
Expand Down Expand Up @@ -512,32 +514,25 @@ impl<T: Config> TryFrom<(T::PoolId, ActiveLoan<T>)> for ActiveLoanInfo<T> {
type Error = DispatchError;

fn try_from((pool_id, active_loan): (T::PoolId, ActiveLoan<T>)) -> Result<Self, Self::Error> {
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,
})
}
}
40 changes: 40 additions & 0 deletions pallets/loans/src/entities/pricing/external.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ use crate::{
#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebugNoBound, MaxEncodedLen)]
#[scale_info(skip_type_params(T))]
pub struct ExternalAmount<T: Config> {
/// 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,
}

Expand Down Expand Up @@ -67,6 +72,11 @@ pub struct ExternalPricing<T: Config> {

/// 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<T: Config> ExternalPricing<T> {
Expand Down Expand Up @@ -148,10 +158,37 @@ impl<T: Config> ExternalActivePricing<T> {
Ok(self.outstanding_quantity.ensure_mul_int(price)?)
}

fn validate_amount(
&self,
amount: &ExternalAmount<T>,
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::<T>::SettlementPriceExceedsVariation
);

Ok(())
}

pub fn max_borrow_amount(
&self,
amount: ExternalAmount<T>,
pool_id: T::PoolId,
) -> Result<T::Balance, DispatchError> {
self.validate_amount(&amount, pool_id)?;

match self.info.max_borrow_amount {
MaxBorrowAmount::Quantity(quantity) => {
let available = quantity.ensure_sub(self.outstanding_quantity)?;
Expand All @@ -164,7 +201,10 @@ impl<T: Config> ExternalActivePricing<T> {
pub fn max_repay_principal(
&self,
amount: ExternalAmount<T>,
pool_id: T::PoolId,
) -> Result<T::Balance, DispatchError> {
self.validate_amount(&amount, pool_id)?;

Ok(self
.outstanding_quantity
.ensure_mul_int(amount.settlement_price)?)
Expand Down
15 changes: 10 additions & 5 deletions pallets/loans/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()?)?;
Expand Down
82 changes: 73 additions & 9 deletions pallets/loans/src/tests/borrow_loan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Runtime>::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::<Runtime>::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::<Runtime>::SettlementPriceExceedsVariation
);
});
}

Expand All @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions pallets/loans/src/tests/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Runtime>;
type Block = frame_system::mocking::MockBlock<Runtime>;
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 0294561

Please sign in to comment.