Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
114 changes: 72 additions & 42 deletions contracts/strategies/blend_leverage/src/leverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32, StrategyError> {
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)))
}
75 changes: 63 additions & 12 deletions contracts/strategies/blend_leverage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<Val>) {
let pool: Address = init_args
.get(0)
Expand Down Expand Up @@ -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);
Expand All @@ -107,6 +112,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy {
c_factor,
target_loops,
min_hf,
orange_hf,
};

storage::set_config(&e, config);
Expand Down Expand Up @@ -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);
Expand All @@ -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<u32, StrategyError> {
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);
Expand Down
3 changes: 3 additions & 0 deletions contracts/strategies/blend_leverage/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading