diff --git a/contracts/market/src/events.rs b/contracts/market/src/events.rs index e6264011..87d65b8f 100644 --- a/contracts/market/src/events.rs +++ b/contracts/market/src/events.rs @@ -210,6 +210,7 @@ struct IssueCoverBadDebt { struct ClaimCoverBadDebtResults { #[topic] obligation_key: ObligationKey, + obligation: Option, } #[contractevent] @@ -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, +) { + ClaimCoverBadDebtResults { obligation_key, obligation }.publish(e); } pub fn bad_debt_request_cancelled(e: &Env, pool_address: &Address, request_id: u64, missing: bool) { diff --git a/contracts/market/src/obligation.rs b/contracts/market/src/obligation.rs index 828129ff..0071fbff 100644 --- a/contracts/market/src/obligation.rs +++ b/contracts/market/src/obligation.rs @@ -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 { + // 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(|_| { @@ -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, @@ -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 @@ -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); } diff --git a/contracts/market/src/processors.rs b/contracts/market/src/processors.rs index bf2a0d63..4bda6eb8 100644 --- a/contracts/market/src/processors.rs +++ b/contracts/market/src/processors.rs @@ -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(()) } diff --git a/contracts/market_manager/src/contract.rs b/contracts/market_manager/src/contract.rs index b726decc..62cc8089 100644 --- a/contracts/market_manager/src/contract.rs +++ b/contracts/market_manager/src/contract.rs @@ -182,8 +182,11 @@ impl MarketManager for MarketManagerContract { new_wasm_hash: BytesN<32>, ) -> Result<(), MMCError> { extend_instance(&e); + require_admin(&e); require_deployed_market(&e, &market_address)?; + let market_admin = market::Client::new(&e, &market_address).get_global_state().admin; + market_admin.require_auth(); if storage::get_queued_in_market_upgrade(&e, &market_address).is_some() { return Err(MMCError::UpgradeAlreadyExists); @@ -217,6 +220,7 @@ impl MarketManager for MarketManagerContract { fn cancel_market_upgrade(e: Env, market_address: Address) -> Result<(), MMCError> { extend_instance(&e); + require_admin(&e); if storage::get_queued_in_market_upgrade(&e, &market_address).is_none() { diff --git a/tests/src/liquidate.rs b/tests/src/liquidate.rs index 08c31d74..c779cf1d 100644 --- a/tests/src/liquidate.rs +++ b/tests/src/liquidate.rs @@ -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, }; @@ -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, + ¢s, + &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, @@ -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:?}" + ); +} diff --git a/wasms/deploy/aggregated_oracle.wasm b/wasms/deploy/aggregated_oracle.wasm index 17afc872..4ba3d58b 100644 Binary files a/wasms/deploy/aggregated_oracle.wasm and b/wasms/deploy/aggregated_oracle.wasm differ diff --git a/wasms/deploy/aqua_swap_provider.wasm b/wasms/deploy/aqua_swap_provider.wasm index 7473d6ac..0274e2af 100644 Binary files a/wasms/deploy/aqua_swap_provider.wasm and b/wasms/deploy/aqua_swap_provider.wasm differ diff --git a/wasms/deploy/controlled_insurance_fund.wasm b/wasms/deploy/controlled_insurance_fund.wasm index 463cbb1b..256d25dc 100644 Binary files a/wasms/deploy/controlled_insurance_fund.wasm and b/wasms/deploy/controlled_insurance_fund.wasm differ diff --git a/wasms/deploy/farms.wasm b/wasms/deploy/farms.wasm index 84969750..4134809f 100644 Binary files a/wasms/deploy/farms.wasm and b/wasms/deploy/farms.wasm differ diff --git a/wasms/deploy/market.wasm b/wasms/deploy/market.wasm index 1116df11..4efdcc87 100644 Binary files a/wasms/deploy/market.wasm and b/wasms/deploy/market.wasm differ diff --git a/wasms/deploy/market_manager.wasm b/wasms/deploy/market_manager.wasm index cb89ab22..0c01b220 100644 Binary files a/wasms/deploy/market_manager.wasm and b/wasms/deploy/market_manager.wasm differ diff --git a/wasms/deploy/redstone_sep_40_adapter.wasm b/wasms/deploy/redstone_sep_40_adapter.wasm index c0423f55..7702a330 100644 Binary files a/wasms/deploy/redstone_sep_40_adapter.wasm and b/wasms/deploy/redstone_sep_40_adapter.wasm differ diff --git a/wasms/deploy_optimized/aggregated_oracle.optimized.wasm b/wasms/deploy_optimized/aggregated_oracle.optimized.wasm index 17afc872..4ba3d58b 100644 Binary files a/wasms/deploy_optimized/aggregated_oracle.optimized.wasm and b/wasms/deploy_optimized/aggregated_oracle.optimized.wasm differ diff --git a/wasms/deploy_optimized/aqua_swap_provider.optimized.wasm b/wasms/deploy_optimized/aqua_swap_provider.optimized.wasm index 7473d6ac..0274e2af 100644 Binary files a/wasms/deploy_optimized/aqua_swap_provider.optimized.wasm and b/wasms/deploy_optimized/aqua_swap_provider.optimized.wasm differ diff --git a/wasms/deploy_optimized/controlled_insurance_fund.optimized.wasm b/wasms/deploy_optimized/controlled_insurance_fund.optimized.wasm index 6d2b6cb6..e96abbdf 100644 Binary files a/wasms/deploy_optimized/controlled_insurance_fund.optimized.wasm and b/wasms/deploy_optimized/controlled_insurance_fund.optimized.wasm differ diff --git a/wasms/deploy_optimized/farms.optimized.wasm b/wasms/deploy_optimized/farms.optimized.wasm index 8b8df845..887869a5 100644 Binary files a/wasms/deploy_optimized/farms.optimized.wasm and b/wasms/deploy_optimized/farms.optimized.wasm differ diff --git a/wasms/deploy_optimized/market.optimized.wasm b/wasms/deploy_optimized/market.optimized.wasm index b354214b..2f2ce558 100644 Binary files a/wasms/deploy_optimized/market.optimized.wasm and b/wasms/deploy_optimized/market.optimized.wasm differ diff --git a/wasms/deploy_optimized/market_manager.optimized.wasm b/wasms/deploy_optimized/market_manager.optimized.wasm index cb89ab22..0c01b220 100644 Binary files a/wasms/deploy_optimized/market_manager.optimized.wasm and b/wasms/deploy_optimized/market_manager.optimized.wasm differ diff --git a/wasms/deploy_optimized/redstone_sep_40_adapter.optimized.wasm b/wasms/deploy_optimized/redstone_sep_40_adapter.optimized.wasm index c0423f55..7702a330 100644 Binary files a/wasms/deploy_optimized/redstone_sep_40_adapter.optimized.wasm and b/wasms/deploy_optimized/redstone_sep_40_adapter.optimized.wasm differ diff --git a/wasms/market.wasm b/wasms/market.wasm index fbac7a66..e61a6222 100644 Binary files a/wasms/market.wasm and b/wasms/market.wasm differ diff --git a/wasms/market_manager.wasm b/wasms/market_manager.wasm index 3f783218..65235080 100644 Binary files a/wasms/market_manager.wasm and b/wasms/market_manager.wasm differ