From 44887e5e710a3616b74d0fbd46c1f6ae810e7aa5 Mon Sep 17 00:00:00 2001 From: NnamdiCyber Date: Mon, 1 Jun 2026 05:07:08 +0000 Subject: [PATCH] D6: partial-unwind liquidation protection - Add orange_hf (trigger) and target_hf (restore-to) fields to Config - Replace compute_unwind_loops with compute_partial_unwind: closed-form minimal repay/withdraw to restore HF to target_hf in one atomic step - Add submit_partial_unwind to blend_pool: single [Withdraw, Repay] submit - rebalance() now triggers on HF < orange_hf and executes partial unwind - Tests: already_at_target, no_debt, single_loop (minimality), max_loops, unhealthy_position --- .../blend_leverage/src/blend_pool.rs | 73 +++++++++++ .../strategies/blend_leverage/src/leverage.rs | 104 +++++++++------ .../strategies/blend_leverage/src/lib.rs | 33 +++-- .../strategies/blend_leverage/src/storage.rs | 6 + .../blend_leverage/src/test_integration.rs | 2 + .../blend_leverage/src/test_leverage.rs | 122 ++++++++++++++---- 6 files changed, 264 insertions(+), 76 deletions(-) diff --git a/contracts/strategies/blend_leverage/src/blend_pool.rs b/contracts/strategies/blend_leverage/src/blend_pool.rs index 6e9b823..f9f08ac 100644 --- a/contracts/strategies/blend_leverage/src/blend_pool.rs +++ b/contracts/strategies/blend_leverage/src/blend_pool.rs @@ -295,6 +295,79 @@ pub fn submit_unwind( Ok((b_removed, d_removed)) } +/// Partial unwind: repay and withdraw exact underlying amounts in a single atomic submit. +/// +/// Used by the orange-zone rebalance path. Both `repay_amount` and `withdraw_amount` +/// are in underlying units. The pool atomically withdraws collateral then repays debt, +/// leaving equity unchanged. +/// +/// Returns (b_tokens_removed, d_tokens_removed). +pub fn submit_partial_unwind( + e: &Env, + repay_amount: i128, + withdraw_amount: i128, + config: &Config, +) -> Result<(i128, i128), StrategyError> { + if repay_amount == 0 && withdraw_amount == 0 { + return Ok((0, 0)); + } + + let pool_client = BlendPoolClient::new(e, &config.pool); + let strategy = e.current_contract_address(); + + let pre_positions = pool_client.get_positions(&strategy); + let pre_b = pre_positions.collateral.get(config.reserve_id).unwrap_or(0); + let pre_d = pre_positions.liabilities.get(config.reserve_id).unwrap_or(0); + + let mut requests: Vec = Vec::new(e); + + if withdraw_amount > 0 { + requests.push_back(Request { + address: config.asset.clone(), + amount: withdraw_amount, + request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL, + }); + } + if repay_amount > 0 { + requests.push_back(Request { + address: config.asset.clone(), + amount: repay_amount, + request_type: REQUEST_TYPE_REPAY, + }); + + let token_client = TokenClient::new(e, &config.asset); + e.authorize_as_current_contract(vec![ + e, + InvokerContractAuthEntry::Contract(SubContractInvocation { + context: ContractContext { + contract: config.asset.clone(), + fn_name: Symbol::new(e, "approve"), + args: ( + strategy.clone(), + config.pool.clone(), + repay_amount, + e.ledger().sequence() + 1u32, + ) + .into_val(e), + }, + sub_invocations: vec![e], + }), + ]); + token_client.approve(&strategy, &config.pool, &repay_amount, &(e.ledger().sequence() + 1)); + } + + pool_client.submit_with_allowance(&strategy, &strategy, &strategy, &requests); + + let new_positions = pool_client.get_positions(&strategy); + let new_b = new_positions.collateral.get(config.reserve_id).unwrap_or(0); + let new_d = new_positions.liabilities.get(config.reserve_id).unwrap_or(0); + + Ok(( + pre_b.checked_sub(new_b).unwrap_or(0), + pre_d.checked_sub(new_d).unwrap_or(0), + )) +} + /// Deleverage by unwinding loops to improve health factor. /// Builds alternating [withdraw, repay, ...] requests and submits atomically. /// Returns (b_tokens_removed, d_tokens_removed). diff --git a/contracts/strategies/blend_leverage/src/leverage.rs b/contracts/strategies/blend_leverage/src/leverage.rs index bf4ecb6..2aa7ee0 100644 --- a/contracts/strategies/blend_leverage/src/leverage.rs +++ b/contracts/strategies/blend_leverage/src/leverage.rs @@ -247,59 +247,79 @@ pub fn check_deposit_safety( Ok(()) } -/// Compute how many loops to unwind to bring HF back above min_hf. -/// Returns the number of (withdraw, repay) pairs needed. -pub fn compute_unwind_loops( +/// Compute the minimal (repay_underlying, withdraw_underlying) to restore HF to target_hf. +/// +/// Derivation (same-asset loop: withdraw and repay the same underlying amount `x`): +/// supply_value = b_tokens × b_rate / SCALAR_12 +/// debt_value = d_tokens × d_rate / SCALAR_12 +/// HF_after = (supply_value - x) × c_factor / (debt_value - x) ≥ target_hf +/// +/// Solving for x: +/// x × (target_hf - c_factor) ≥ target_hf × debt_value - supply_value × c_factor +/// x ≥ numerator / (target_hf - c_factor) [when target_hf > c_factor] +/// +/// Returns (repay_underlying, withdraw_underlying). +/// Both values are equal (withdraw exactly what you repay to keep equity intact). +/// Returns (0, 0) if HF is already ≥ target_hf or there is no debt. +pub fn compute_partial_unwind( b_tokens: i128, d_tokens: i128, b_rate: i128, d_rate: i128, c_factor: i128, - min_hf: i128, -) -> Result { - let mut current_b = b_tokens; - let mut current_d = d_tokens; - let mut loops = 0u32; - - loop { - let hf = compute_health_factor(current_b, current_d, b_rate, d_rate, c_factor)?; - if hf >= min_hf || current_d == 0 { - break; - } + target_hf: i128, +) -> Result<(i128, i128), StrategyError> { + if d_tokens == 0 { + return Ok((0, 0)); + } - // Unwind one loop: withdraw enough collateral to repay one "layer" of debt. - // The last borrow layer ≈ d_tokens × (c_factor/SCALAR_7)^n pattern. - // Simplified: repay an amount equal to current_d × (1 - c_factor/SCALAR_7) - // which is approximately the smallest borrow layer. - let repay_amount = current_d - .checked_mul(SCALAR_7 - c_factor) - .ok_or(StrategyError::ArithmeticError)? - / SCALAR_7; + let hf = compute_health_factor(b_tokens, d_tokens, b_rate, d_rate, c_factor)?; + if hf >= target_hf { + return Ok((0, 0)); + } - if repay_amount == 0 { - break; - } + // supply_value and debt_value in underlying units (SCALAR_12 cancels) + let supply_value = b_tokens + .fixed_mul_floor(b_rate, SCALAR_12) + .ok_or(StrategyError::ArithmeticError)?; + let debt_value = d_tokens + .fixed_mul_floor(d_rate, SCALAR_12) + .ok_or(StrategyError::ArithmeticError)?; + + // numerator = target_hf × debt_value - supply_value × c_factor (all in 1e7 scale) + let target_debt = target_hf + .checked_mul(debt_value) + .ok_or(StrategyError::ArithmeticError)?; + let weighted_supply = supply_value + .checked_mul(c_factor) + .ok_or(StrategyError::ArithmeticError)?; - // The d_tokens burned = repay_underlying / d_rate * SCALAR_12 - let d_tokens_burned = repay_amount - .fixed_mul_floor(SCALAR_12, d_rate) - .ok_or(StrategyError::ArithmeticError)?; + if target_debt <= weighted_supply { + return Ok((0, 0)); + } - // The b_tokens withdrawn = withdraw_underlying / b_rate * SCALAR_12 - // We withdraw same amount as we repay (netting) - let b_tokens_withdrawn = repay_amount - .fixed_mul_floor(SCALAR_12, b_rate) - .ok_or(StrategyError::ArithmeticError)?; + let numerator = target_debt + .checked_sub(weighted_supply) + .ok_or(StrategyError::UnderflowOverflow)?; - current_d = current_d.checked_sub(d_tokens_burned).unwrap_or(0); - current_b = current_b.checked_sub(b_tokens_withdrawn).unwrap_or(0); - loops += 1; + // denominator = target_hf - c_factor (both 1e7 scaled) + let denominator = target_hf + .checked_sub(c_factor) + .ok_or(StrategyError::UnderflowOverflow)?; - if loops >= 5 { - // Safety limit on unwind iterations - break; - } + if denominator <= 0 { + // target_hf ≤ c_factor: impossible to reach by partial unwind; repay all debt. + return Ok((debt_value, supply_value)); } - Ok(loops) + // x = ceil(numerator / denominator) — round up to guarantee HF ≥ target after integer math + let x = numerator + .checked_add(denominator - 1) + .ok_or(StrategyError::ArithmeticError)? + .checked_div(denominator) + .ok_or(StrategyError::DivisionByZero)?; + + // Cap at total debt value (can't repay more than owed) + let repay = x.min(debt_value); + Ok((repay, repay)) } diff --git a/contracts/strategies/blend_leverage/src/lib.rs b/contracts/strategies/blend_leverage/src/lib.rs index d878a71..70c9465 100644 --- a/contracts/strategies/blend_leverage/src/lib.rs +++ b/contracts/strategies/blend_leverage/src/lib.rs @@ -15,7 +15,7 @@ mod test_integration; use constants::SCALAR_12; pub use defindex_strategy_core::{event, DeFindexStrategyTrait, StrategyError}; use leverage::{ - check_deposit_safety, compute_health_factor, compute_totals, compute_unwind_loops, + check_deposit_safety, compute_health_factor, compute_partial_unwind, compute_totals, shares_to_underlying, }; use soroban_sdk::{ @@ -49,6 +49,8 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { /// [5] c_factor: i128 — collateral factor (1e7) /// [6] target_loops: u32 — number of leverage loops /// [7] min_hf: i128 — minimum health factor (1e7) + /// [8] orange_hf: i128 — orange-zone trigger HF (1e7); partial unwind fires below this + /// [9] target_hf: i128 — HF to restore to after partial unwind (1e7) fn __constructor(e: Env, asset: Address, init_args: Vec) { let pool: Address = init_args .get(0) @@ -82,6 +84,14 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { .get(7) .expect("Missing: min_hf") .into_val(&e); + let orange_hf: i128 = init_args + .get(8) + .expect("Missing: orange_hf") + .into_val(&e); + let target_hf: i128 = init_args + .get(9) + .expect("Missing: target_hf") + .into_val(&e); // Look up the reserve index from the pool let pool_client = blend_contract_sdk::pool::Client::new(&e, &pool); @@ -107,6 +117,8 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { c_factor, target_loops, min_hf, + orange_hf, + target_hf, }; storage::set_config(&e, config); @@ -301,7 +313,9 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { #[contractimpl] impl BlendLeverageStrategy { - /// Rebalance: auto-deleverage if health factor is below min_hf. + /// Rebalance: partial-unwind if HF drops into the orange zone (below orange_hf). + /// Computes the minimal repay amount to restore HF to target_hf. + /// Falls back to full deleverage if HF drops below min_hf. /// Callable by anyone (permissionless — protects the vault). pub fn rebalance(e: Env) -> Result<(), StrategyError> { extend_instance_ttl(&e); @@ -316,22 +330,21 @@ impl BlendLeverageStrategy { let hf = compute_health_factor(b_tokens, d_tokens, b_rate, d_rate, config.c_factor)?; - if hf >= config.min_hf { - return Ok(()); // HF is healthy + if hf >= config.orange_hf { + return Ok(()); // HF is healthy — above orange zone } - // Compute how many loops to unwind - let unwind_count = compute_unwind_loops( - b_tokens, d_tokens, b_rate, d_rate, config.c_factor, config.min_hf, + // Orange zone or below: compute minimal unwind to reach target_hf + let (repay, withdraw) = compute_partial_unwind( + b_tokens, d_tokens, b_rate, d_rate, config.c_factor, config.target_hf, )?; - if unwind_count == 0 { + if repay == 0 { return Ok(()); } - // Execute deleverage let (b_removed, d_removed) = - blend_pool::submit_deleverage(&e, unwind_count, &config)?; + blend_pool::submit_partial_unwind(&e, repay, withdraw, &config)?; // Update reserves accounting reserves::deleverage(&e, b_removed, d_removed, &config)?; diff --git a/contracts/strategies/blend_leverage/src/storage.rs b/contracts/strategies/blend_leverage/src/storage.rs index 686d722..7d57583 100644 --- a/contracts/strategies/blend_leverage/src/storage.rs +++ b/contracts/strategies/blend_leverage/src/storage.rs @@ -46,6 +46,12 @@ pub struct Config { pub target_loops: u32, /// Minimum health factor (1e7 scaled, e.g. 1_050_000 = 1.05) pub min_hf: i128, + /// Orange-zone trigger: if HF drops below this, partial unwind fires. + /// Must be > min_hf. (1e7 scaled, e.g. 1_150_000 = 1.15) + pub orange_hf: i128, + /// Target health factor to restore to after partial unwind (1e7 scaled). + /// Must be >= orange_hf. + pub target_hf: i128, } pub fn set_config(e: &Env, config: Config) { diff --git a/contracts/strategies/blend_leverage/src/test_integration.rs b/contracts/strategies/blend_leverage/src/test_integration.rs index ae49e48..d1f1063 100644 --- a/contracts/strategies/blend_leverage/src/test_integration.rs +++ b/contracts/strategies/blend_leverage/src/test_integration.rs @@ -148,6 +148,8 @@ fn make_config(e: &Env, pool_addr: &Address, token: &Address, blnd: &Address) -> c_factor: 9_000_000, // 0.90: below pool's c=0.95 to keep HF > 1.0 target_loops: 3, min_hf: 10_500_000, + orange_hf: 11_500_000, + target_hf: 12_000_000, } } diff --git a/contracts/strategies/blend_leverage/src/test_leverage.rs b/contracts/strategies/blend_leverage/src/test_leverage.rs index cb359d2..3802e5c 100644 --- a/contracts/strategies/blend_leverage/src/test_leverage.rs +++ b/contracts/strategies/blend_leverage/src/test_leverage.rs @@ -4,8 +4,8 @@ use crate::constants::{FIRST_DEPOSIT_LOCKUP, SCALAR_12, SCALAR_7}; use crate::leverage::{ - compute_equity, compute_health_factor, compute_loop_pairs, compute_totals, compute_unwind_loops, - shares_to_underlying, underlying_to_shares, + compute_equity, compute_health_factor, compute_loop_pairs, compute_partial_unwind, + compute_totals, shares_to_underlying, underlying_to_shares, }; use crate::storage::LeverageReserves; @@ -312,50 +312,120 @@ fn test_hf_below_one() { assert!(hf < SCALAR_7); // HF < 1.0 → liquidatable } -// ── compute_unwind_loops ───────────────────────────────────────────────────── +// ── compute_partial_unwind ─────────────────────────────────────────────────── +/// Already at target HF — should return (0, 0). #[test] -fn test_unwind_healthy_position_returns_zero() { - // HF = 1.9 >> 1.05 → no unwinding needed - let loops = compute_unwind_loops( +fn test_partial_unwind_already_at_target() { + // HF = 1.9 >> target 1.15 → no unwind needed + let (repay, withdraw) = compute_partial_unwind( 2_000_0000000, 1_000_0000000, SCALAR_12, SCALAR_12, 9_500_000, - 10_500_000, // min_hf = 1.05 + 11_500_000, // target_hf = 1.15 ).unwrap(); - assert_eq!(loops, 0); + assert_eq!(repay, 0); + assert_eq!(withdraw, 0); } +/// No debt — should return (0, 0). #[test] -fn test_unwind_unhealthy_position() { - // HF just below 1.05 → should need some unwinding - // b=10500, d=9500, c=0.95 → HF = 10500*0.95/9500 ≈ 1.05 exactly - // Make it slightly below: b=10499, d=9500 - let loops = compute_unwind_loops( - 10_499_0000000, - 9_500_0000000, +fn test_partial_unwind_no_debt() { + let (repay, withdraw) = compute_partial_unwind( + 1_000_0000000, + 0, SCALAR_12, SCALAR_12, 9_500_000, - 10_500_000, + 11_500_000, ).unwrap(); - assert!(loops > 0, "Should need at least 1 unwind loop"); - assert!(loops <= 5, "Should not exceed safety limit"); + assert_eq!(repay, 0); + assert_eq!(withdraw, 0); } +/// Single-loop position (2x leverage): HF just below target, minimal repay. #[test] -fn test_unwind_no_debt() { - let loops = compute_unwind_loops( - 1_000_0000000, - 0, +fn test_partial_unwind_single_loop() { + // 2x leverage: b=2000, d=1000, c=0.95 → HF = 2000*0.95/1000 = 1.9 + // Drop HF to just below 1.15 by increasing debt: b=2000, d=1650 + // HF = 2000*0.95/1650 ≈ 1.1515 — just above 1.15, so no unwind needed. + // Use b=2000, d=1700 → HF = 2000*0.95/1700 ≈ 1.1176 < 1.15 → needs unwind. + // + // Solve: (2000 - x)*0.95 / (1700 - x) = 1.15 + // 1900 - 0.95x = 1955 - 1.15x → 0.2x = 55 → x = 275 + let (repay, withdraw) = compute_partial_unwind( + 2_000_0000000, + 1_700_0000000, SCALAR_12, SCALAR_12, 9_500_000, - 10_500_000, + 11_500_000, // target_hf = 1.15 + ).unwrap(); + assert!(repay > 0, "Should need some repayment"); + assert_eq!(repay, withdraw, "repay and withdraw must be equal"); + + // Verify the resulting HF is >= target + let new_supply = 2_000_0000000 - withdraw; + let new_debt = 1_700_0000000 - repay; + let hf_after = compute_health_factor(new_supply, new_debt, SCALAR_12, SCALAR_12, 9_500_000).unwrap(); + assert!(hf_after >= 11_500_000, "HF after unwind should be >= target: got {}", hf_after); + + // Verify it's minimal: one stroop less repayment would leave HF below target + if repay > 1 { + let hf_less = compute_health_factor( + 2_000_0000000 - (withdraw - 1), + 1_700_0000000 - (repay - 1), + SCALAR_12, SCALAR_12, 9_500_000, + ).unwrap(); + assert!(hf_less < 11_500_000, "One stroop less should be below target: got {}", hf_less); + } +} + +/// Max-loops position (near-max leverage): large repay needed. +#[test] +fn test_partial_unwind_max_loops() { + // 13-loop position: b≈8300, d≈7300, c=0.95 + // HF = 8300*0.95/7300 ≈ 1.0795 — below orange_hf=1.15, target=1.20 + let b = 8_300_0000000_i128; + let d = 7_300_0000000_i128; + let c = 9_500_000_i128; + let target = 12_000_000_i128; // 1.20 + + let (repay, withdraw) = compute_partial_unwind(b, d, SCALAR_12, SCALAR_12, c, target).unwrap(); + assert!(repay > 0); + assert_eq!(repay, withdraw); + + // Verify HF after + let hf_after = compute_health_factor(b - withdraw, d - repay, SCALAR_12, SCALAR_12, c).unwrap(); + assert!(hf_after >= target, "HF after max-loop unwind should be >= target: got {}", hf_after); + + // Repay should be less than total debt (partial, not full close) + assert!(repay < d, "Partial unwind should not repay all debt"); +} + +/// Unhealthy position (HF just below target): verify positive repay and HF restoration. +#[test] +fn test_partial_unwind_unhealthy_position() { + // b=10499, d=9500, c=0.95 → HF = 10499*0.95/9500 ≈ 1.04989 < 1.05 + let (repay, withdraw) = compute_partial_unwind( + 10_499_0000000, + 9_500_0000000, + SCALAR_12, + SCALAR_12, + 9_500_000, + 10_500_000, // target_hf = 1.05 + ).unwrap(); + assert!(repay > 0); + assert_eq!(repay, withdraw); + + let hf_after = compute_health_factor( + 10_499_0000000 - withdraw, + 9_500_0000000 - repay, + SCALAR_12, SCALAR_12, 9_500_000, ).unwrap(); - assert_eq!(loops, 0); + assert!(hf_after >= 10_500_000, "HF should be restored to target: got {}", hf_after); } // ── Leverage table validation (cross-reference with simulate.rs) ───────────── @@ -642,6 +712,8 @@ fn test_safety_rejects_high_utilization() { c_factor: 9_500_000, target_loops: 8, min_hf: 10_500_000, + orange_hf: 11_500_000, + target_hf: 12_000_000, }; // Pool at 96% utilization → should panic (above 95% limit) @@ -677,6 +749,8 @@ fn test_safety_allows_healthy_pool() { c_factor: 9_500_000, target_loops: 8, min_hf: 10_500_000, + orange_hf: 11_500_000, + target_hf: 12_000_000, }; // Pool at 50% utilization, healthy HF