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
145 changes: 94 additions & 51 deletions contracts/strategies/blend_leverage/src/blend_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,73 +157,73 @@ pub fn submit_unwind(

let pre_balance = token_client.balance(&strategy);

// Build atomic unwind: [withdraw, repay] × N steps + [withdraw equity].
// Build atomic unwind: [repay, withdraw] × N steps + [withdraw equity].
// Repay first so HF improves before the collateral withdrawal is checked.
// Split d_tokens_to_remove evenly across target_loops steps.
// Each step withdraws and repays the same amount, maintaining HF.
// The final withdraw extracts the equity (b - d difference).
let mut requests: Vec<Request> = Vec::new(e);
let mut total_repay = 0i128;

let n_steps = config.target_loops.max(1);
let repay_per_step = d_tokens_to_remove / n_steps as i128;

// Check if this is a full close (removing all debt)
let pool_client_inner = BlendPoolClient::new(e, &config.pool);
let cur_positions = pool_client_inner.get_positions(&strategy);
let total_d = cur_positions
.liabilities
.get(config.reserve_id)
.unwrap_or(0);
let is_full_close = d_tokens_to_remove >= total_d;

for i in 0..n_steps {
let is_last = i == n_steps - 1;

// For repay: only use i64::MAX on full close's last step (cleans dust).
// For partial unwinds, use exact amounts so the pool doesn't repay all debt.
let repay_amount = if is_last && is_full_close {
i64::MAX as i128
} else if is_last {
d_tokens_to_remove - repay_per_step * (n_steps as i128 - 1)
} else {
repay_per_step
};

// Withdraw same amount as repay in each pair — this frees collateral to cover repayment.
// The equity portion (b_tokens - d_tokens) is withdrawn separately at the end.
let withdraw_amount = if is_last && is_full_close {
// For full close, withdraw same as the repay dust-cleaning amount
d_tokens_to_remove - repay_per_step * (n_steps as i128 - 1)
} else if is_last {
d_tokens_to_remove - repay_per_step * (n_steps as i128 - 1)
} else {
repay_per_step
};
// i64::MAX is the full-close sentinel: sweep all debt and collateral in one step.
// This avoids per-step HF failures caused by interest accrual between accounting
// snapshots and the actual pool state at unwind time.
let is_full_close = d_tokens_to_remove >= i64::MAX as i128;

// For a full close, use a single repay+withdraw sweep to avoid per-step HF
// failures caused by interest accrual since the last accounting update.
if is_full_close {
requests.push_back(Request {
address: config.asset.clone(),
amount: withdraw_amount,
request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL,
});
requests.push_back(Request {
address: config.asset.clone(),
amount: repay_amount,
amount: i64::MAX as i128,
request_type: REQUEST_TYPE_REPAY,
});
total_repay += repay_amount;
}

// Final: withdraw equity portion (collateral minus debt that was removed)
let equity_withdraw = b_tokens_to_remove
.checked_sub(d_tokens_to_remove)
.unwrap_or(0);

if equity_withdraw > 0 {
requests.push_back(Request {
address: config.asset.clone(),
amount: equity_withdraw,
amount: i64::MAX as i128,
request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL,
});
total_repay = i64::MAX as i128;
} else {
let repay_per_step = d_tokens_to_remove / n_steps as i128;

for i in 0..n_steps {
let is_last = i == n_steps - 1;

let repay_amount = if is_last {
d_tokens_to_remove - repay_per_step * (n_steps as i128 - 1)
} else {
repay_per_step
};

let withdraw_amount = repay_amount;

// Repay first (improves HF), then withdraw collateral.
requests.push_back(Request {
address: config.asset.clone(),
amount: repay_amount,
request_type: REQUEST_TYPE_REPAY,
});
requests.push_back(Request {
address: config.asset.clone(),
amount: withdraw_amount,
request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL,
});
total_repay += repay_amount;
}

// Final: withdraw equity portion (collateral minus debt removed).
let equity_withdraw = b_tokens_to_remove
.checked_sub(d_tokens_to_remove)
.unwrap_or(0);
if equity_withdraw > 0 {
requests.push_back(Request {
address: config.asset.clone(),
amount: equity_withdraw,
request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL,
});
}
}

// Approve pool to spend total repay amount via allowance
Expand Down Expand Up @@ -402,6 +402,49 @@ pub fn submit_deleverage(
))
}

// ── Unwind to address (Migration) ────────────────────────────────────────────

/// Unwind a proportional position and transfer the resulting equity to `to`.
/// Used by migrate(): V1 unwinds the user's share → equity goes to V2 → V2 re-leverages.
/// Returns the equity amount transferred.
pub fn unwind_to(
e: &Env,
b_tokens_to_remove: i128,
d_tokens_to_remove: i128,
to: &Address,
config: &Config,
) -> Result<i128, StrategyError> {
let token_client = TokenClient::new(e, &config.asset);
let strategy = e.current_contract_address();
let pre_balance = token_client.balance(&strategy);

// Unwind to self first so we can measure the exact equity received
submit_unwind(e, b_tokens_to_remove, d_tokens_to_remove, &strategy, config)?;

let post_balance = token_client.balance(&strategy);
let equity = post_balance
.checked_sub(pre_balance)
.ok_or(StrategyError::UnderflowOverflow)?;

if equity > 0 && to != &strategy {
e.authorize_as_current_contract(vec![
e,
InvokerContractAuthEntry::Contract(SubContractInvocation {
context: ContractContext {
contract: config.asset.clone(),
fn_name: Symbol::new(e, "transfer"),
args: (strategy.clone(), to.clone(), equity).into_val(e),
},
sub_invocations: vec![e],
}),
]);
token_client.transfer(&strategy, to, &equity);
}

Ok(equity)
}


// ── Claim BLND emissions ─────────────────────────────────────────────────────

/// Claim BLND emissions from both supply and borrow sides.
Expand Down
130 changes: 128 additions & 2 deletions contracts/strategies/blend_leverage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use leverage::{
use soroban_sdk::{
contract, contractimpl, token::TokenClient, Address, Bytes, Env, IntoVal, String, Val, Vec,
};
use storage::{extend_instance_ttl, Config};
use storage::{extend_instance_ttl, Config, STRATEGY_VERSION};

fn check_positive_amount(amount: i128) -> Result<(), StrategyError> {
if amount <= 0 {
Expand Down Expand Up @@ -111,6 +111,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy {

storage::set_config(&e, config);
storage::set_keeper(&e, &keeper);
storage::set_version(&e, STRATEGY_VERSION);
}

fn asset(e: Env) -> Result<Address, StrategyError> {
Expand Down Expand Up @@ -348,12 +349,18 @@ impl BlendLeverageStrategy {
Ok(())
}

/// Get the current keeper address.
/// Get current keeper address.
pub fn get_keeper(e: Env) -> Result<Address, StrategyError> {
extend_instance_ttl(&e);
Ok(storage::get_keeper(&e))
}

/// Get the strategy version number (1 = V1, 2 = V2, …).
pub fn get_version(e: Env) -> Result<u32, StrategyError> {
extend_instance_ttl(&e);
Ok(storage::get_version(&e))
}

/// Get current health factor (1e7 scaled).
pub fn health_factor(e: Env) -> Result<i128, StrategyError> {
extend_instance_ttl(&e);
Expand All @@ -379,4 +386,123 @@ impl BlendLeverageStrategy {
reserves.d_rate,
))
}

/// Migrate user's position from this strategy (V1) to a new strategy (V2) atomically.
///
/// Flow (single transaction):
/// 1. Require user's signature.
/// 2. Burn user's V1 shares; compute their proportional b/d tokens.
/// 3. Unwind that position on Blend pool → equity (underlying) lands in V1.
/// 4. Transfer equity to V2.
/// 5. Call V2.receive_migration(from, equity) — V2 re-leverages and mints V2 shares.
///
/// HF is preserved because the unwind and re-leverage are symmetric:
/// the same equity is re-deployed at the same c_factor/target_loops.
pub fn migrate(e: Env, from: Address, to_strategy: Address) -> Result<(), StrategyError> {
extend_instance_ttl(&e);
from.require_auth();

let config = storage::get_config(&e);
let reserves = reserves::get_strategy_reserves_updated(&e, &config);

// Burn V1 shares and get proportional b/d tokens.
// Treat as a full close when the user owns all non-lockup shares: the
// FIRST_DEPOSIT_LOCKUP shares are permanently locked in total_shares but
// never credited to any user, so user_shares == total_shares - LOCKUP for
// a sole depositor. A full-close uses a single atomic repay+withdraw sweep
// (i64::MAX sentinel) which avoids per-step HF failures after interest accrual.
let user_shares = storage::get_vault_shares(&e, &from);
let is_full_close = reserves.total_shares
.checked_sub(user_shares)
.unwrap_or(0)
<= crate::constants::FIRST_DEPOSIT_LOCKUP;

let (b_tokens_to_remove, d_tokens_to_remove, _) =
reserves::migrate_withdraw(&e, &from, &reserves)?;

// For a full close, use pool-actual positions to handle interest accrual
// since the last accounting update (d_rate may have grown).
// Pass i64::MAX as d_tokens_to_remove so submit_unwind takes the full-close
// path regardless of any rate accrual between the two get_positions calls.
let (unwind_b, unwind_d) = if is_full_close {
let (pool_b, _) = blend_pool::get_strategy_positions(&e, &config);
(pool_b, i64::MAX as i128)
} else {
(b_tokens_to_remove, d_tokens_to_remove)
};

// Unwind position on Blend pool; equity is transferred to V2
let equity = blend_pool::unwind_to(
&e,
unwind_b,
unwind_d,
&to_strategy,
&config,
)?;

if equity <= 0 {
return Err(StrategyError::UnderlyingAmountBelowMin);
}

// V2 re-leverages the pre-funded tokens and mints shares for `from`
e.invoke_contract::<i128>(
&to_strategy,
&soroban_sdk::Symbol::new(&e, "receive_migration"),
soroban_sdk::vec![&e, from.into_val(&e), equity.into_val(&e)],
);

Ok(())
}

/// Accept pre-funded underlying tokens from a V1 migration and re-leverage them.
///
/// Called by V1's `migrate()` after it has already transferred `amount` underlying
/// tokens to this contract. Re-leverages and mints shares for `to`.
/// No `transfer_from` is performed — tokens are already in this contract.
pub fn receive_migration(e: Env, to: Address, amount: i128) -> Result<i128, StrategyError> {
extend_instance_ttl(&e);
check_positive_amount(amount)?;

let config = storage::get_config(&e);
let reserves = reserves::get_strategy_reserves_updated(&e, &config);

// Re-leverage the tokens already held by this contract
let (b_delta, d_delta) = blend_pool::submit_leverage_loop(&e, amount, &config)?;

let (vault_shares, updated_reserves) =
reserves::deposit(&e, &to, b_delta, d_delta, &reserves)?;

let underlying_balance = shares_to_underlying(vault_shares, &updated_reserves)?;

event::emit_deposit(
&e,
String::from_str(&e, STRATEGY_NAME),
amount,
to,
);

Ok(underlying_balance)
}

/// Absorbs unassigned b_tokens and d_tokens that were transferred to this strategy.
/// Kept for emergency recovery.
pub fn absorb(e: Env, to: Address) -> Result<i128, StrategyError> {
extend_instance_ttl(&e);

let config = storage::get_config(&e);
let reserves = reserves::get_strategy_reserves_updated(&e, &config);

let (pool_b, pool_d) = blend_pool::get_strategy_positions(&e, &config);

let b_diff = pool_b.checked_sub(reserves.total_b_tokens).unwrap_or(0);
let d_diff = pool_d.checked_sub(reserves.total_d_tokens).unwrap_or(0);

if b_diff <= 0 && d_diff <= 0 {
return Ok(0);
}

let (vault_shares, _) = reserves::deposit(&e, &to, b_diff, d_diff, &reserves)?;
Ok(vault_shares)
}
}

41 changes: 41 additions & 0 deletions contracts/strategies/blend_leverage/src/reserves.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,47 @@ pub fn withdraw(
Ok((remaining, b_tokens_to_remove, d_tokens_to_remove, reserves))
}

/// Account for a full withdrawal due to strategy migration.
/// Burns all user shares and returns their proportional b/d tokens.
pub fn migrate_withdraw(
e: &Env,
from: &Address,
reserves: &LeverageReserves,
) -> Result<(i128, i128, LeverageReserves), StrategyError> {
let mut reserves = reserves.clone();

let vault_shares = storage::get_vault_shares(e, from);
if vault_shares <= 0 {
return Err(StrategyError::InsufficientBalance);
}

let b_tokens_to_remove = vault_shares
.fixed_mul_floor(reserves.total_b_tokens, reserves.total_shares)
.ok_or(StrategyError::ArithmeticError)?;
let d_tokens_to_remove = vault_shares
.fixed_mul_floor(reserves.total_d_tokens, reserves.total_shares)
.ok_or(StrategyError::ArithmeticError)?;

reserves.total_shares = reserves
.total_shares
.checked_sub(vault_shares)
.ok_or(StrategyError::UnderflowOverflow)?;
reserves.total_b_tokens = reserves
.total_b_tokens
.checked_sub(b_tokens_to_remove)
.ok_or(StrategyError::UnderflowOverflow)?;
reserves.total_d_tokens = reserves
.total_d_tokens
.checked_sub(d_tokens_to_remove)
.ok_or(StrategyError::UnderflowOverflow)?;

storage::set_vault_shares(e, from, 0);
storage::set_strategy_reserves(e, reserves.clone());

Ok((b_tokens_to_remove, d_tokens_to_remove, reserves))
}


// ── Harvest accounting ───────────────────────────────────────────────────────

/// Account for harvested rewards that have been re-leveraged.
Expand Down
Loading
Loading