diff --git a/contracts/strategies/blend_leverage/src/leverage.rs b/contracts/strategies/blend_leverage/src/leverage.rs index bf4ecb6..62594b5 100644 --- a/contracts/strategies/blend_leverage/src/leverage.rs +++ b/contracts/strategies/blend_leverage/src/leverage.rs @@ -247,59 +247,89 @@ 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 underlying amount to repay (and withdraw) to restore HF +/// to `target_hf`, and the number of leverage loops that covers it. +/// +/// Closed-form derivation (all values in underlying units): +/// B = b_tokens × b_rate / SCALAR_12 (supply value) +/// D = d_tokens × d_rate / SCALAR_12 (debt value) +/// HF = B × c_factor / D (current, in 1e7) +/// +/// After repaying x underlying (and withdrawing x collateral): +/// (B - x) × c_factor = target_hf × (D - x) +/// x = (B × c_factor - target_hf × D) / (c_factor - target_hf) +/// +/// Returns `(repay_underlying, loops_needed)`. +/// Returns `(0, 0)` if already at or above target_hf, or if 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, u32), 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 and debt values in underlying (SCALAR_12 precision) + 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)?; - // 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)?; + // numerator = B × c_factor - target_hf × D (both in 1e7 × underlying) + // denominator = c_factor - target_hf (in 1e7) + // x = numerator / denominator + let numerator = supply_value + .checked_mul(c_factor) + .ok_or(StrategyError::ArithmeticError)? + .checked_sub( + target_hf + .checked_mul(debt_value) + .ok_or(StrategyError::ArithmeticError)?, + ) + .ok_or(StrategyError::UnderflowOverflow)?; - // 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)?; + // denominator = c_factor - target_hf; negative when target_hf > c_factor (always true for + // a healthy target), so we negate both sides. + let denom = target_hf + .checked_sub(c_factor) + .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; + if denom <= 0 { + // target_hf <= c_factor: can't reach target by partial unwind alone + return Err(StrategyError::ArithmeticError); + } - if loops >= 5 { - // Safety limit on unwind iterations - break; - } + // x = -numerator / denom (numerator is negative when HF < target_hf) + let repay_underlying = numerator + .checked_neg() + .ok_or(StrategyError::ArithmeticError)? + .checked_div(denom) + .ok_or(StrategyError::DivisionByZero)? + + 1; // +1 stroop to ensure we clear the threshold + + // Convert repay amount to loop count. + // Each loop layer ≈ initial × c_factor^k. The smallest layer (last borrow) ≈ + // total_debt × (1 - c_factor/SCALAR_7). We count how many layers sum to repay_underlying. + let layer_size = debt_value + .checked_mul(SCALAR_7 - c_factor) + .ok_or(StrategyError::ArithmeticError)? + / SCALAR_7; + + if layer_size == 0 { + return Ok((repay_underlying, 1)); } - Ok(loops) + let loops = ((repay_underlying + layer_size - 1) / layer_size) as u32; + Ok((repay_underlying, loops.max(1).min(20))) } diff --git a/contracts/strategies/blend_leverage/src/lib.rs b/contracts/strategies/blend_leverage/src/lib.rs index d878a71..cbc4cda 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,7 @@ 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 threshold; partial unwind triggered below this (1e7) fn __constructor(e: Env, asset: Address, init_args: Vec) { let pool: Address = init_args .get(0) @@ -82,6 +83,10 @@ 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); // Look up the reserve index from the pool let pool_client = blend_contract_sdk::pool::Client::new(&e, &pool); @@ -107,6 +112,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { c_factor, target_loops, min_hf, + orange_hf, }; storage::set_config(&e, config); @@ -301,7 +307,8 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { #[contractimpl] impl BlendLeverageStrategy { - /// Rebalance: auto-deleverage if health factor is below min_hf. + /// Rebalance: partial-unwind if HF is in the orange zone (orange_hf > HF >= min_hf), + /// or full deleverage if HF < min_hf. /// Callable by anyone (permissionless — protects the vault). pub fn rebalance(e: Env) -> Result<(), StrategyError> { extend_instance_ttl(&e); @@ -311,34 +318,78 @@ impl BlendLeverageStrategy { let (b_tokens, d_tokens) = blend_pool::get_strategy_positions(&e, &config); if d_tokens == 0 { - return Ok(()); // No debt, nothing to rebalance + return Ok(()); } 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, no action needed } - // 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, + // Use orange_hf as target so we restore to the safe zone, not just min_hf + let (_, unwind_loops) = compute_partial_unwind( + b_tokens, d_tokens, b_rate, d_rate, config.c_factor, config.orange_hf, )?; - if unwind_count == 0 { + if unwind_loops == 0 { return Ok(()); } - // Execute deleverage let (b_removed, d_removed) = - blend_pool::submit_deleverage(&e, unwind_count, &config)?; + blend_pool::submit_deleverage(&e, unwind_loops, &config)?; - // Update reserves accounting reserves::deleverage(&e, b_removed, d_removed, &config)?; Ok(()) } + /// Partial-unwind liquidation protection: unwind just enough loops to restore + /// HF to `target_hf`. Callable by the keeper or the strategy itself. + /// + /// `target_hf` must be >= config.orange_hf to prevent abuse. + pub fn partial_unwind(e: Env, caller: Address, target_hf: i128) -> Result { + extend_instance_ttl(&e); + + // Keeper or permissionless if HF is already in orange zone + let config = storage::get_config(&e); + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + let (b_tokens, d_tokens) = blend_pool::get_strategy_positions(&e, &config); + + if d_tokens == 0 { + return Ok(0); + } + + let hf = compute_health_factor(b_tokens, d_tokens, b_rate, d_rate, config.c_factor)?; + + // Only the keeper can trigger above the orange zone; anyone can trigger inside it + if hf >= config.orange_hf { + let keeper = storage::get_keeper(&e); + if caller != keeper { + return Err(StrategyError::NotAuthorized); + } + } + caller.require_auth(); + + // target_hf must be at least orange_hf to avoid over-unwinding + let effective_target = target_hf.max(config.orange_hf); + + let (_, loops) = compute_partial_unwind( + b_tokens, d_tokens, b_rate, d_rate, config.c_factor, effective_target, + )?; + + if loops == 0 { + return Ok(0); + } + + let (b_removed, d_removed) = + blend_pool::submit_deleverage(&e, loops, &config)?; + + reserves::deleverage(&e, b_removed, d_removed, &config)?; + + Ok(loops) + } + /// Set a new keeper address. Only the current keeper can call this. pub fn set_keeper(e: Env, new_keeper: Address) -> Result<(), StrategyError> { extend_instance_ttl(&e); diff --git a/contracts/strategies/blend_leverage/src/storage.rs b/contracts/strategies/blend_leverage/src/storage.rs index 686d722..a456339 100644 --- a/contracts/strategies/blend_leverage/src/storage.rs +++ b/contracts/strategies/blend_leverage/src/storage.rs @@ -46,6 +46,9 @@ 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 threshold: HF below this triggers partial unwind (1e7 scaled). + /// Must satisfy min_hf < orange_hf. e.g. 1_150_000 = 1.15 + pub orange_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..0c63430 100644 --- a/contracts/strategies/blend_leverage/src/test_integration.rs +++ b/contracts/strategies/blend_leverage/src/test_integration.rs @@ -148,6 +148,7 @@ 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, } } diff --git a/contracts/strategies/blend_leverage/src/test_leverage.rs b/contracts/strategies/blend_leverage/src/test_leverage.rs index cb359d2..7cf9f24 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,130 @@ fn test_hf_below_one() { assert!(hf < SCALAR_7); // HF < 1.0 → liquidatable } -// ── compute_unwind_loops ───────────────────────────────────────────────────── +// ── compute_partial_unwind ─────────────────────────────────────────────────── #[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_returns_zero() { + // HF = 1.9 >> target 1.15 → no unwind needed + let (repay, loops) = 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!(repay, 0); assert_eq!(loops, 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_returns_zero() { + let (repay, loops) = 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!(loops, 0); } #[test] -fn test_unwind_no_debt() { - let loops = compute_unwind_loops( - 1_000_0000000, - 0, +fn test_partial_unwind_single_loop_position() { + // 1-loop position: b=1950, d=950, c=0.95 + // HF = 1950*0.95/950 = 1.95 → healthy, no unwind + let (repay, loops) = compute_partial_unwind( + 1_950_0000000, + 950_0000000, SCALAR_12, SCALAR_12, 9_500_000, - 10_500_000, + 11_500_000, ).unwrap(); + assert_eq!(repay, 0); assert_eq!(loops, 0); + + // Now make it unhealthy: b=1100, d=1000, c=0.95 → HF = 1.045 < 1.15 + let (repay2, loops2) = compute_partial_unwind( + 1_100_0000000, + 1_000_0000000, + SCALAR_12, + SCALAR_12, + 9_500_000, + 11_500_000, + ).unwrap(); + assert!(repay2 > 0, "Should need repayment"); + assert!(loops2 >= 1, "Should need at least 1 loop"); + + // Verify the repay amount actually restores HF + // After repaying x: new_b = 1100 - x, new_d = 1000 - x + // HF_new = (1100-x)*0.95 / (1000-x) >= 1.15 + let x = repay2; + let new_b = 1_100_0000000 - x; + let new_d = 1_000_0000000 - x; + if new_d > 0 { + let hf_new = compute_health_factor(new_b, new_d, SCALAR_12, SCALAR_12, 9_500_000).unwrap(); + assert!(hf_new >= 11_500_000, "HF after unwind={} should be >= target 1.15", hf_new); + } +} + +#[test] +fn test_partial_unwind_max_loops_position() { + // 20-loop position (max): very high leverage, HF just below orange zone + // b=20000, d=19000, c=0.95 → HF = 20000*0.95/19000 ≈ 1.0 + let (repay, loops) = compute_partial_unwind( + 20_000_0000000, + 19_000_0000000, + SCALAR_12, + SCALAR_12, + 9_500_000, + 11_500_000, // target = 1.15 + ).unwrap(); + assert!(repay > 0); + assert!(loops >= 1 && loops <= 20, "loops={} out of range", loops); + + // Verify restoration + let new_b = 20_000_0000000 - repay; + let new_d = 19_000_0000000 - repay; + if new_d > 0 { + let hf_new = compute_health_factor(new_b, new_d, SCALAR_12, SCALAR_12, 9_500_000).unwrap(); + assert!(hf_new >= 11_500_000, "HF after unwind={} should be >= 1.15", hf_new); + } +} + +#[test] +fn test_partial_unwind_minimal_repay_is_exact() { + // Verify the closed-form gives the minimum repay (not over-unwinding). + // b=10500, d=9500, c=0.95 → HF = 10500*0.95/9500 ≈ 1.05 + // target = 1.15 + let (repay, _) = compute_partial_unwind( + 10_500_0000000, + 9_500_0000000, + SCALAR_12, + SCALAR_12, + 9_500_000, + 11_500_000, + ).unwrap(); + + // Repaying 1 less stroop should leave HF below target + if repay > 1 { + let x_minus = repay - 2; + let new_b = 10_500_0000000 - x_minus; + let new_d = 9_500_0000000 - x_minus; + let hf_short = compute_health_factor(new_b, new_d, SCALAR_12, SCALAR_12, 9_500_000).unwrap(); + assert!(hf_short < 11_500_000, "Repaying less should leave HF below target"); + } + + // Repaying the computed amount should reach target + let new_b = 10_500_0000000 - repay; + let new_d = 9_500_0000000 - repay; + if new_d > 0 { + let hf_ok = compute_health_factor(new_b, new_d, SCALAR_12, SCALAR_12, 9_500_000).unwrap(); + assert!(hf_ok >= 11_500_000, "HF after exact repay={} should be >= target", hf_ok); + } } // ── Leverage table validation (cross-reference with simulate.rs) ───────────── @@ -642,6 +722,7 @@ fn test_safety_rejects_high_utilization() { c_factor: 9_500_000, target_loops: 8, min_hf: 10_500_000, + orange_hf: 11_500_000, }; // Pool at 96% utilization → should panic (above 95% limit) @@ -677,6 +758,7 @@ fn test_safety_allows_healthy_pool() { c_factor: 9_500_000, target_loops: 8, min_hf: 10_500_000, + orange_hf: 11_500_000, }; // Pool at 50% utilization, healthy HF diff --git a/frontend/index.html b/frontend/index.html index f904f23..1c4b7a4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,807 +1,1389 @@ - + - - - - Turbolong - - - - - - - - -