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
73 changes: 73 additions & 0 deletions contracts/strategies/blend_leverage/src/blend_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Request> = 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).
Expand Down
104 changes: 62 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,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<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, 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))
}
33 changes: 23 additions & 10 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,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<Val>) {
let pool: Address = init_args
.get(0)
Expand Down Expand Up @@ -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);
Expand All @@ -107,6 +117,8 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy {
c_factor,
target_loops,
min_hf,
orange_hf,
target_hf,
};

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

Expand Down
Loading
Loading