Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions contracts/market/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ struct IssueCoverBadDebt {
struct ClaimCoverBadDebtResults {
#[topic]
obligation_key: ObligationKey,
obligation: Option<Obligation>,
}

#[contractevent]
Expand Down Expand Up @@ -725,8 +726,12 @@ pub fn issue_cover_bad_debt(e: &Env, obligation_key: ObligationKey) {
IssueCoverBadDebt { obligation_key }.publish(e);
}

pub fn claim_cover_bad_debt_results(e: &Env, obligation_key: ObligationKey) {
ClaimCoverBadDebtResults { obligation_key }.publish(e);
pub fn claim_cover_bad_debt_results(
e: &Env,
obligation_key: ObligationKey,
obligation: Option<Obligation>,
) {
ClaimCoverBadDebtResults { obligation_key, obligation }.publish(e);
}

pub fn bad_debt_request_cancelled(e: &Env, pool_address: &Address, request_id: u64, missing: bool) {
Expand Down
26 changes: 22 additions & 4 deletions contracts/market/src/obligation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,9 +377,14 @@ impl Obligation {
}

// Computes the current collateral assets summed value(deposit shares + plain collateral) per
// obligation, scaling each value with the appropriate `close_ltv_bps` value
fn compute_collateral_value_scaled_w_close_ltvs(&self, e: &Env) -> Result<i128, MCError> {
// obligation, scaling each value with the appropriate `close_ltv_bps` value.
// As a second value, it returns the amount of open positions that are used as collateral that can back up borrows
fn compute_collateral_value_scaled_w_close_ltvs(
&self,
e: &Env,
) -> Result<(i128, u32), MCError> {
let mut value_sum = 0_i128;
let mut positions_with_non_zero_close_ltv_count = 0u32;

for (pool_address, deposit_position) in self.deposits.iter() {
let pool = Pool::try_get(e, &pool_address).map_err(|_| {
Expand All @@ -388,6 +393,9 @@ impl Obligation {
MCError::InternalError
})?;

if pool.config.health_config.close_ltv_bps.is_positive() {
positions_with_non_zero_close_ltv_count += 1;
}
let new_value_term = Self::compute_pool_collateral_value_scaled(
e,
&pool,
Expand All @@ -398,7 +406,7 @@ impl Obligation {
value_sum = value_sum.checked_add(new_value_term).map_over_or_underflow()?;
}

Ok(value_sum)
Ok((value_sum, positions_with_non_zero_close_ltv_count))
}

// Computes the current collateral assets summed value(deposit shares + plain collateral) per
Expand Down Expand Up @@ -927,10 +935,20 @@ impl Obligation {
.ok_or(MCError::BorrowPositionDoesNotExist)?,
);

let (obligation_debt_value_w_liability_factors, obligation_collateral_value_w_close_ltvs) = (
let (
obligation_debt_value_w_liability_factors,
(obligation_collateral_value_w_close_ltvs, positions_with_non_zero_close_ltv_count),
) = (
self.compute_debt_value_scaled_w_liability_factors(e)?,
self.compute_collateral_value_scaled_w_close_ltvs(e)?,
);

let min_collateral_value_requirement = compute_min_collateral_threshold_scaled(e)?
.checked_mul(positions_with_non_zero_close_ltv_count as i128)
.map_over_or_underflow()?;
let obligation_collateral_value_w_close_ltvs = obligation_collateral_value_w_close_ltvs
.saturating_sub(min_collateral_value_requirement);

if obligation_debt_value_w_liability_factors <= obligation_collateral_value_w_close_ltvs {
return Err(MCError::ObligationIsHealthy);
}
Expand Down
4 changes: 2 additions & 2 deletions contracts/market/src/processors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,12 +1064,12 @@ pub fn process_claim_cover_bad_debt_results(

if obligation.is_empty() {
obligation.remove(e, &obligation_key);
events::claim_cover_bad_debt_results(e, obligation_key, None);
} else {
obligation.set(e, &obligation_key);
events::claim_cover_bad_debt_results(e, obligation_key, Some(obligation));
}

events::claim_cover_bad_debt_results(e, obligation_key);

Ok(())
}

Expand Down
184 changes: 183 additions & 1 deletion tests/src/liquidate.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![cfg(test)]

use market::{
constants::{BPS_FACTOR, DEFAULT_BAD_DEBT_LOCK_D},
constants::{BPS_FACTOR, DEFAULT_BAD_DEBT_LOCK_D, DEFAULT_MAX_POSITIONS},
error::MCError,
obligation::ObligationKey,
};
Expand Down Expand Up @@ -172,6 +172,21 @@ impl LiquidationTest {
self.fixture.contract_client.refresh_pool(&self.collateral_pool_address);
}

fn set_min_collateral_value_cents(&self, cents: i128) {
let update_in_queue_period =
self.fixture.contract_client.get_global_state().update_in_queue_period;

self.fixture.contract_client.queue_in_market_update(
&DEFAULT_MAX_POSITIONS,
&cents,
&DEFAULT_BAD_DEBT_LOCK_D,
);
self.fixture.e.ledger().with_mut(|li| li.timestamp += update_in_queue_period);
self.fixture.contract_client.refresh_pool(&self.borrow_pool_address);
self.fixture.contract_client.refresh_pool(&self.collateral_pool_address);
self.fixture.contract_client.apply_market_update();
}

fn ltv(&self) -> i128 {
compute_unparameterized_ltv_bps(
&self.fixture.e,
Expand Down Expand Up @@ -853,3 +868,170 @@ fn test_liquidate_with_excess_repay_amount_refunds_difference() {
Err(MCError::ObligationDoesNotExist)
);
}

#[test]
fn test_liquidate_below_min_collateral_while_close_ltv_solvent() {
let test = LiquidationTest::new();
test.set_min_collateral_value_cents(100);

let debt_before = test.debt();

let result = test.fixture.contract_client.try_liquidate(
&test.liquidator,
&ObligationKey::new(test.borrower.clone()),
&test.borrow_pool_address,
&test.collateral_pool_address,
&test.max_liquidation_amount(),
&0,
);

assert!(
result.is_ok(),
"Position below min_collateral_value_cents must be liquidatable even when close-LTV \
solvent, got: {result:?}"
);
assert!(test.debt() < debt_before, "Debt should be reduced by the liquidation");
}

#[test]
fn test_below_min_collateral_liquidation_not_rejected_as_healthy() {
let test = LiquidationTest::new();
test.set_min_collateral_value_cents(100);

let result = test.fixture.contract_client.try_liquidate(
&test.liquidator,
&ObligationKey::new(test.borrower.clone()),
&test.borrow_pool_address,
&test.collateral_pool_address,
&1,
&0,
);

assert_ne!(
result,
Err(Ok(MCError::ObligationIsHealthy)),
"Sub-minimum collateral positions must not be treated as healthy"
);
}

#[test]
fn test_healthy_position_above_min_collateral_still_healthy() {
let test = LiquidationTest::new();

let result = test.fixture.contract_client.try_liquidate(
&test.liquidator,
&ObligationKey::new(test.borrower.clone()),
&test.borrow_pool_address,
&test.collateral_pool_address,
&1,
&0,
);

assert_eq!(result, Err(Ok(MCError::ObligationIsHealthy)));
}

#[test]
fn test_min_collateral_deduction_scales_with_position_count() {
let fixture = TestMarketFixture::new();

let liquidity_provider = fixture.users[0].clone();
let liquidator = fixture.users[1].clone();

let single_pos_borrower = fixture.users[2].clone();
let split_pos_borrower = fixture.users[3].clone();

let borrow_pool = fixture.usdc_pool_address.clone();
let gold_pool = fixture.gold_pool_address.clone();
let btc_pool = fixture.btc_pool_address.clone();

let total_collateral: i128 = 5_000_000;
let half_collateral: i128 = total_collateral / 2;
let debt: i128 = 2_500_000;

// Deep borrow-pool liquidity so both borrows are serviceable.
fixture.contract_client.deposit(
&ObligationKey::new(liquidity_provider.clone()),
&borrow_pool,
&(10 * total_collateral),
&None,
);

// Borrower A: all collateral in a single pool (N = 1).
fixture.contract_client.add_collateral(
&ObligationKey::new(single_pos_borrower.clone()),
&gold_pool,
&total_collateral,
&None,
);
fixture.contract_client.borrow(
&ObligationKey::new(single_pos_borrower.clone()),
&borrow_pool,
&debt,
&None,
);

// Borrower B: same total collateral value & debt, split across two pools (N = 2).
fixture.contract_client.add_collateral(
&ObligationKey::new(split_pos_borrower.clone()),
&gold_pool,
&half_collateral,
&None,
);
fixture.contract_client.add_collateral(
&ObligationKey::new(split_pos_borrower.clone()),
&btc_pool,
&half_collateral,
&None,
);
fixture.contract_client.borrow(
&ObligationKey::new(split_pos_borrower.clone()),
&borrow_pool,
&debt,
&None,
);

// Raise min_collateral_value_cents to 10 cents -> threshold 1e13.
let update_in_queue_period = fixture.contract_client.get_global_state().update_in_queue_period;
fixture.contract_client.queue_in_market_update(
&DEFAULT_MAX_POSITIONS,
&10,
&DEFAULT_BAD_DEBT_LOCK_D,
);
fixture.e.ledger().with_mut(|li| li.timestamp += update_in_queue_period);
fixture.contract_client.refresh_pool(&borrow_pool);
fixture.contract_client.refresh_pool(&gold_pool);
fixture.contract_client.refresh_pool(&btc_pool);
fixture.contract_client.apply_market_update();

// N = 1: deduction is a single threshold -> still healthy.
let single_pos_result = fixture.contract_client.try_liquidate(
&liquidator,
&ObligationKey::new(single_pos_borrower.clone()),
&borrow_pool,
&gold_pool,
&1,
&0,
);
assert_eq!(
single_pos_result,
Err(Ok(MCError::ObligationIsHealthy)),
"Single-position borrower must stay healthy: only 1 * threshold is reserved"
);

// N = 2: deduction is two thresholds -> now liquidatable.
// Position is solvent, so the close factor caps repayment at 50% of debt;
// repay a meaningful slice so shares are actually burned.
let split_pos_result = fixture.contract_client.try_liquidate(
&liquidator,
&ObligationKey::new(split_pos_borrower.clone()),
&borrow_pool,
&gold_pool,
&(debt / 4),
&0,
);
assert!(
split_pos_result.is_ok(),
"Two-position borrower must be liquidatable: 2 * threshold is reserved, pushing the \
close-LTV collateral below the debt. Got: {split_pos_result:?}"
);
}
Binary file modified wasms/deploy/aggregated_oracle.wasm
Binary file not shown.
Binary file modified wasms/deploy/aqua_swap_provider.wasm
Binary file not shown.
Binary file modified wasms/deploy/controlled_insurance_fund.wasm
Binary file not shown.
Binary file modified wasms/deploy/farms.wasm
Binary file not shown.
Binary file modified wasms/deploy/market.wasm
Binary file not shown.
Binary file modified wasms/deploy/redstone_sep_40_adapter.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/aggregated_oracle.optimized.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/aqua_swap_provider.optimized.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/controlled_insurance_fund.optimized.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/farms.optimized.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/market.optimized.wasm
Binary file not shown.
Binary file modified wasms/deploy_optimized/redstone_sep_40_adapter.optimized.wasm
Binary file not shown.
Binary file modified wasms/market.wasm
Binary file not shown.