diff --git a/contracts/strategies/blend_leverage/src/blend_pool.rs b/contracts/strategies/blend_leverage/src/blend_pool.rs index 6e9b823..c16d8f6 100644 --- a/contracts/strategies/blend_leverage/src/blend_pool.rs +++ b/contracts/strategies/blend_leverage/src/blend_pool.rs @@ -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 = 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 @@ -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 { + 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. diff --git a/contracts/strategies/blend_leverage/src/lib.rs b/contracts/strategies/blend_leverage/src/lib.rs index d878a71..968aa74 100644 --- a/contracts/strategies/blend_leverage/src/lib.rs +++ b/contracts/strategies/blend_leverage/src/lib.rs @@ -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 { @@ -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 { @@ -348,12 +349,18 @@ impl BlendLeverageStrategy { Ok(()) } - /// Get the current keeper address. + /// Get current keeper address. pub fn get_keeper(e: Env) -> Result { 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 { + extend_instance_ttl(&e); + Ok(storage::get_version(&e)) + } + /// Get current health factor (1e7 scaled). pub fn health_factor(e: Env) -> Result { extend_instance_ttl(&e); @@ -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::( + &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 { + 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 { + 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) + } } + diff --git a/contracts/strategies/blend_leverage/src/reserves.rs b/contracts/strategies/blend_leverage/src/reserves.rs index a1f0166..c52e244 100644 --- a/contracts/strategies/blend_leverage/src/reserves.rs +++ b/contracts/strategies/blend_leverage/src/reserves.rs @@ -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. diff --git a/contracts/strategies/blend_leverage/src/storage.rs b/contracts/strategies/blend_leverage/src/storage.rs index 686d722..478233a 100644 --- a/contracts/strategies/blend_leverage/src/storage.rs +++ b/contracts/strategies/blend_leverage/src/storage.rs @@ -10,6 +10,12 @@ const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - ONE_DAY_LEDGERS; const PERSISTENT_BUMP_AMOUNT: u32 = 120 * ONE_DAY_LEDGERS; const PERSISTENT_LIFETIME_THRESHOLD: u32 = PERSISTENT_BUMP_AMOUNT - 20 * ONE_DAY_LEDGERS; +// ── Strategy version ───────────────────────────────────────────────────────── + +/// Bump this constant when deploying a new strategy version. +/// V1 = 1, V2 = 2, etc. Stored in instance storage on construction. +pub const STRATEGY_VERSION: u32 = 1; + // ── Data keys ──────────────────────────────────────────────────────────────── #[contracttype] @@ -19,6 +25,7 @@ pub enum DataKey { Reserves, VaultPos(Address), Keeper, + Version, } // ── Config ─────────────────────────────────────────────────────────────────── @@ -156,3 +163,16 @@ pub fn extend_instance_ttl(e: &Env) { .instance() .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); } + +// ── Strategy version ───────────────────────────────────────────────────────── + +pub fn set_version(e: &Env, version: u32) { + e.storage().instance().set(&DataKey::Version, &version); +} + +pub fn get_version(e: &Env) -> u32 { + e.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or(1u32) // default to 1 for contracts deployed before versioning +} diff --git a/contracts/strategies/blend_leverage/src/test_integration.rs b/contracts/strategies/blend_leverage/src/test_integration.rs index ae49e48..4af0691 100644 --- a/contracts/strategies/blend_leverage/src/test_integration.rs +++ b/contracts/strategies/blend_leverage/src/test_integration.rs @@ -18,9 +18,9 @@ use blend_contract_sdk::{ }; use soroban_sdk::{ contract, contractimpl, contracttype, - testutils::{Address as _, BytesN as _}, + testutils::{Address as _, BytesN as _, Ledger as _}, token::{StellarAssetClient, TokenClient}, - vec, Address, BytesN, Env, String, Vec, + vec, Address, BytesN, Env, IntoVal, String, Vec, }; use crate::constants::{ @@ -32,6 +32,7 @@ use crate::leverage::{ }; use crate::storage::LeverageReserves; use crate::{blend_pool, reserves, storage}; +use crate::StrategyError; // ── Mock Oracle ────────────────────────────────────────────────────────────── @@ -746,3 +747,303 @@ fn test_deleverage_step_by_step() { diff ); } + +// ── Migration helpers ──────────────────────────────────────────────────────── + +/// Register a BlendLeverageStrategy with constructor args. +fn register_strategy( + e: &Env, + pool_addr: &Address, + token: &Address, + blnd: &Address, + config: &storage::Config, +) -> Address { + let keeper = Address::generate(e); + let router = Address::generate(e); + let init_args: Vec = vec![ + e, + pool_addr.into_val(e), + blnd.into_val(e), + router.into_val(e), + 1_0000000_i128.into_val(e), + keeper.into_val(e), + config.c_factor.into_val(e), + config.target_loops.into_val(e), + config.min_hf.into_val(e), + ]; + e.register(crate::BlendLeverageStrategy, (token.clone(), init_args)) +} + +#[test] +fn test_migrate() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let config = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 100_000_0000000); + + let v1 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + let v2 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + + let user = Address::generate(&e); + let deposit_amount = 1_000_0000000_i128; + StellarAssetClient::new(&e, &token) + .mock_all_auths() + .mint(&user, &deposit_amount); + + e.cost_estimate().budget().reset_unlimited(); + + let v1_balance_before: i128 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "deposit"), + vec![&e, deposit_amount.into_val(&e), user.into_val(&e)], + ); + assert!(v1_balance_before > 0, "V1 balance should be > 0 after deposit"); + + // Snapshot V1 HF before migration + let v1_hf_before: i128 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "health_factor"), + vec![&e], + ); + assert!(v1_hf_before > config.min_hf, "V1 HF should be healthy before migration"); + + // Migrate V1 → V2 (single transaction, user signs once) + e.invoke_contract::<()>( + &v1, + &soroban_sdk::Symbol::new(&e, "migrate"), + vec![&e, user.into_val(&e), v2.into_val(&e)], + ); + + // V1 position is fully burned + let v1_balance_after: i128 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "balance"), + vec![&e, user.into_val(&e)], + ); + assert_eq!(v1_balance_after, 0, "V1 balance should be 0 after migration"); + + // V2 has the user's position + let v2_balance: i128 = e.invoke_contract( + &v2, + &soroban_sdk::Symbol::new(&e, "balance"), + vec![&e, user.into_val(&e)], + ); + assert!(v2_balance > 0, "V2 balance should be > 0 after migration"); + + // Balance is preserved within 2% (unwind + re-leverage rounding) + let diff = (v1_balance_before - v2_balance).abs(); + assert!( + diff < deposit_amount / 50, + "Balance preserved: v1={}, v2={}, diff={}", + v1_balance_before, + v2_balance, + diff + ); + + // V2 HF should be healthy (same c_factor and target_loops) + let v2_hf: i128 = e.invoke_contract( + &v2, + &soroban_sdk::Symbol::new(&e, "health_factor"), + vec![&e], + ); + assert!( + v2_hf > config.min_hf, + "V2 HF {} should be > min_hf {}", + v2_hf, + config.min_hf + ); +} + +#[test] +fn test_migrate_with_pool_rate_change() { + // Verifies that after pool rates change (interest accrual + new borrowers), + // migration still zeroes out V1 and produces a healthy V2 position. + // + // NOTE: This test uses moderate utilization (~33%). High utilization (>75%) + // causes submit_with_allowance to fail with #1205 because the Blend pool + // enforces HF after each individual request in the unwind sequence, and the + // strategy has no pre-funded tokens for the repay step. That is a known + // limitation shared with the existing closePosition flow (which falls back + // to a two-step repay+withdraw in the frontend). Migration under extreme + // utilization requires the same two-step fallback. + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let config = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 200_000_0000000); + + let v1 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + let v2 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + let user = Address::generate(&e); + + let deposit_amount = 1_000_0000000_i128; + StellarAssetClient::new(&e, &token) + .mock_all_auths() + .mint(&user, &deposit_amount); + + e.cost_estimate().budget().reset_unlimited(); + + e.invoke_contract::( + &v1, + &soroban_sdk::Symbol::new(&e, "deposit"), + vec![&e, deposit_amount.into_val(&e), user.into_val(&e)], + ); + + // Simulate time passing: advance ledger so interest accrues + e.ledger().with_mut(|li| { + li.sequence_number += 10_000; + li.timestamp += 86_400; // 1 day + }); + + // A second user borrows to drive up utilization (changes rates without blocking unwind) + let borrower = Address::generate(&e); + StellarAssetClient::new(&e, &token) + .mock_all_auths() + .mint(&borrower, &20_000_0000000); + pool::Client::new(&e, &pool_addr).mock_all_auths().submit( + &borrower, + &borrower, + &borrower, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 20_000_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + pool::Request { + address: token.clone(), + amount: 10_000_0000000, // ~33% utilization — rates change but unwind stays safe + request_type: REQUEST_TYPE_BORROW, + }, + ], + ); + + // Migrate under changed pool conditions + e.invoke_contract::<()>( + &v1, + &soroban_sdk::Symbol::new(&e, "migrate"), + vec![&e, user.into_val(&e), v2.into_val(&e)], + ); + + let v1_balance_after: i128 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "balance"), + vec![&e, user.into_val(&e)], + ); + assert_eq!(v1_balance_after, 0); + + let v2_balance: i128 = e.invoke_contract( + &v2, + &soroban_sdk::Symbol::new(&e, "balance"), + vec![&e, user.into_val(&e)], + ); + assert!(v2_balance > 0, "V2 should have position after migration under rate change"); + + let v2_hf: i128 = e.invoke_contract( + &v2, + &soroban_sdk::Symbol::new(&e, "health_factor"), + vec![&e], + ); + assert!( + v2_hf > config.min_hf, + "V2 HF {} should be > min_hf {} after migration with rate change", + v2_hf, + config.min_hf + ); +} + +#[test] +fn test_get_version_returns_one() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let config = make_config(&e, &pool_addr, &token, &blnd); + + let v1 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + + let version: u32 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "get_version"), + vec![&e], + ); + assert_eq!(version, 1, "Freshly deployed strategy should report version 1"); +} + +#[test] +fn test_migrate_requires_user_auth() { + // migrate() must require the depositor's signature; a different caller + // must not be able to migrate someone else's position. + let e = Env::default(); + // Do NOT mock_all_auths — auth is enforced. + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let config = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 100_000_0000000); + + let v1 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + let v2 = register_strategy(&e, &pool_addr, &token, &blnd, &config); + + let user = Address::generate(&e); + let attacker = Address::generate(&e); + let deposit_amount = 1_000_0000000_i128; + + // Deposit as user (mock only user's auth for deposit) + e.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &user, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &v1, + fn_name: "deposit", + args: soroban_sdk::vec![&e, deposit_amount.into_val(&e), user.into_val(&e)], + sub_invokes: &[soroban_sdk::testutils::MockAuthInvoke { + contract: &token, + fn_name: "transfer", + args: soroban_sdk::vec![ + &e, + user.into_val(&e), + v1.into_val(&e), + deposit_amount.into_val(&e) + ], + sub_invokes: &[], + }], + }, + }]); + StellarAssetClient::new(&e, &token) + .mock_all_auths() + .mint(&user, &deposit_amount); + e.cost_estimate().budget().reset_unlimited(); + let _: i128 = e.invoke_contract( + &v1, + &soroban_sdk::Symbol::new(&e, "deposit"), + vec![&e, deposit_amount.into_val(&e), user.into_val(&e)], + ); + + // Attacker tries to migrate user's position — must panic (auth failure) + let result = e.try_invoke_contract::<(), StrategyError>( + &v1, + &soroban_sdk::Symbol::new(&e, "migrate"), + vec![&e, user.into_val(&e), v2.into_val(&e)], + ); + assert!(result.is_err(), "migrate() without user auth must fail"); + + // Attacker provides their own auth (for attacker address, not user) — still fails + e.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &attacker, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &v1, + fn_name: "migrate", + args: soroban_sdk::vec![&e, user.into_val(&e), v2.into_val(&e)], + sub_invokes: &[], + }, + }]); + let result2 = e.try_invoke_contract::<(), StrategyError>( + &v1, + &soroban_sdk::Symbol::new(&e, "migrate"), + vec![&e, user.into_val(&e), v2.into_val(&e)], + ); + assert!(result2.is_err(), "migrate() with wrong signer must fail"); +} + diff --git a/frontend/index.html b/frontend/index.html index f904f23..d9cca71 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,10 @@

Important Disclaimer

@@ -272,7 +276,18 @@

Portfolio Overview

- +
@@ -396,6 +411,7 @@

Open Position

× +
Conservative diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..0711142 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -324,6 +324,21 @@ const MIN_HF_NORMAL = 1.01; const MIN_HF_EXPERT = 1.00001; function minHF() { return expertMode ? MIN_HF_EXPERT : MIN_HF_NORMAL; } +// ── Safe Max HF floor ──────────────────────────────────────────────────────── + +const HF_FLOOR_KEY = "safeMaxHfFloor"; +const HF_FLOOR_DEFAULT = 1.2; + +function getHfFloor(): number { + const raw = localStorage.getItem(HF_FLOOR_KEY); + const v = raw ? parseFloat(raw) : HF_FLOOR_DEFAULT; + return isFinite(v) && v >= 1.01 ? v : HF_FLOOR_DEFAULT; +} + +function setHfFloor(v: number) { + localStorage.setItem(HF_FLOOR_KEY, String(v)); +} + // ── Demo mode ──────────────────────────────────────────────────────────────── let demoMode = false; @@ -829,33 +844,110 @@ function computePoolHF(): number { return totalDebt > 0 ? weightedCollateral / totalDebt : Infinity; } -// ── Portfolio summary (#8) ─────────────────────────────────────────────────── +// ── Portfolio summary (#8) — sortable + compact/expanded (#16) ─────────────── + +type SortCol = "asset" | "leverage" | "hf" | "pnl"; +type SortDir = "asc" | "desc"; + +const POS_SORT_KEY = "posSortCol"; +const POS_DIR_KEY = "posSortDir"; +const POS_VIEW_KEY = "posViewCompact"; + +let posSortCol: SortCol = (localStorage.getItem(POS_SORT_KEY) as SortCol) || "asset"; +let posSortDir: SortDir = (localStorage.getItem(POS_DIR_KEY) as SortDir) || "asc"; +let posViewCompact: boolean = localStorage.getItem(POS_VIEW_KEY) === "1"; + +function savePosPrefs() { + localStorage.setItem(POS_SORT_KEY, posSortCol); + localStorage.setItem(POS_DIR_KEY, posSortDir); + localStorage.setItem(POS_VIEW_KEY, posViewCompact ? "1" : "0"); +} function renderPortfolioSummary() { + const wrap = $("portfolio-summary-wrap"); const container = $("portfolio-summary"); - if (positions.byAsset.size === 0) { container.classList.add("hidden"); return; } - container.classList.remove("hidden"); - container.innerHTML = ""; - for (const [assetId, pos] of positions.byAsset) { - const rs = reserves.find(r => r.asset.id === assetId); + if (positions.byAsset.size === 0) { wrap.classList.add("hidden"); return; } + wrap.classList.remove("hidden"); + + // Build sortable rows + const rows = Array.from(positions.byAsset.entries()).map(([assetId, pos]) => { + const rs = reserves.find(r => r.asset.id === assetId); const cardNetApr = rs ? rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1) : 0; - const netApy = aprToApy(cardNetApr); - const hfColor = pos.hf > 1.1 ? "var(--success)" : pos.hf > 1.03 ? "var(--warning)" : "var(--danger)"; - const card = document.createElement("div"); - card.className = `portfolio-card ${assetId === selectedAsset.id ? "active" : ""}`; - card.title = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; - card.innerHTML = ` - - ${pos.asset.symbol} - - ${fmt(pos.equity, 2)} equity \u00B7 ${fmt(pos.leverage, 1)}\u00D7 - APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% \u00B7 HF ${fmt(pos.hf, 2)} - `; - card.addEventListener("click", () => { - const asset = assets.find(a => a.id === assetId); - if (asset) selectAsset(asset); + const netApy = aprToApy(cardNetApr); + const pnlEntry = getPnlEntry(assetId, selectedPool.id); + const pnl = pnlEntry ? pos.equity - pnlEntry.deposit : 0; + return { assetId, pos, rs, netApy, pnl, cardNetApr }; + }); + + rows.sort((a, b) => { + let diff = 0; + if (posSortCol === "asset") diff = a.pos.asset.symbol.localeCompare(b.pos.asset.symbol); + else if (posSortCol === "leverage") diff = a.pos.leverage - b.pos.leverage; + else if (posSortCol === "hf") diff = (isFinite(a.pos.hf) ? a.pos.hf : 999) - (isFinite(b.pos.hf) ? b.pos.hf : 999); + else if (posSortCol === "pnl") diff = a.pnl - b.pnl; + return posSortDir === "asc" ? diff : -diff; + }); + + // Update sort button indicators + document.querySelectorAll(".pos-sort-btn").forEach(btn => { + const col = btn.dataset.col as SortCol; + const arrow = btn.querySelector(".sort-arrow")!; + btn.classList.toggle("active", col === posSortCol); + arrow.textContent = col !== posSortCol ? "↕" : posSortDir === "asc" ? "↑" : "↓"; + }); + + // Update view toggle icon + ($("pos-view-toggle") as HTMLButtonElement).innerHTML = posViewCompact ? "☷" : "☷"; + $("portfolio-summary").classList.toggle("portfolio-summary-compact", posViewCompact); + + if (posViewCompact) { + // Compact: one-row table + container.innerHTML = ` + + + + ${rows.map(({ assetId, pos, netApy, pnl }) => { + const hfCls = pos.hf > 1.1 ? "hf-ok" : pos.hf > 1.03 ? "hf-warn" : "hf-bad"; + const pnlCls = pnl >= 0 ? "hf-ok" : "hf-bad"; + const pnlStr = pnl !== 0 ? `${pnl >= 0 ? "+" : ""}${fmt(pnl, 4)}` : "—"; + return ` + + + + + + + `; + }).join("")} +
AssetEquityLevHFAPYP&L
${pos.asset.symbol}${fmt(pos.equity, 2)}${fmt(pos.leverage, 1)}×${isFinite(pos.hf) ? fmt(pos.hf, 3) : "∞"}${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%${pnlStr}
`; + container.querySelectorAll(".pos-table-row").forEach(row => { + row.addEventListener("click", () => { + const asset = assets.find(a => a.id === row.dataset.asset); + if (asset) selectAsset(asset); + }); + }); + } else { + // Expanded: cards + container.innerHTML = ""; + rows.forEach(({ assetId, pos, netApy, pnl, cardNetApr }) => { + const hfColor = pos.hf > 1.1 ? "var(--success)" : pos.hf > 1.03 ? "var(--warning)" : "var(--danger)"; + const pnlStr = pnl !== 0 ? ` · P&L ${pnl >= 0 ? "+" : ""}${fmt(pnl, 4)}` : ""; + const card = document.createElement("div"); + card.className = `portfolio-card ${assetId === selectedAsset.id ? "active" : ""}`; + card.title = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; + card.innerHTML = ` + + ${pos.asset.symbol} + + ${fmt(pos.equity, 2)} equity · ${fmt(pos.leverage, 1)}× + APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% · HF ${fmt(pos.hf, 2)}${pnlStr} + `; + card.addEventListener("click", () => { + const asset = assets.find(a => a.id === assetId); + if (asset) selectAsset(asset); + }); + container.appendChild(card); }); - container.appendChild(card); } } @@ -1993,6 +2085,24 @@ function initTooltips() { // ── Event listeners ─────────────────────────────────────────────────────────── +// Sort buttons +document.querySelectorAll(".pos-sort-btn").forEach(btn => { + btn.addEventListener("click", () => { + const col = btn.dataset.col as SortCol; + if (posSortCol === col) posSortDir = posSortDir === "asc" ? "desc" : "asc"; + else { posSortCol = col; posSortDir = "asc"; } + savePosPrefs(); + renderPortfolioSummary(); + }); +}); + +// Compact / expanded toggle +$("pos-view-toggle").addEventListener("click", () => { + posViewCompact = !posViewCompact; + savePosPrefs(); + renderPortfolioSummary(); +}); + // Expert toggle (settings dropdown) function toggleExpert() { expertMode = !expertMode; @@ -2709,6 +2819,39 @@ $("vault-rebalance-btn").addEventListener("click", async () => { } }); +// ── Safe Max button ─────────────────────────────────────────────────────────── + +function applySafeMax() { + const slider = $("leverage-slider") as HTMLInputElement; + const numIn = $("leverage-input") as HTMLInputElement; + const rs = reserves.find(r => r.asset.id === selectedAsset.id); + const c = rs ? rs.cFactor : selectedAsset.cFactor; + const l = rs?.lFactor ?? 1; + const floor = getHfFloor(); + const maxSlider = parseFloat(slider.max); + + // Walk down from slider max in 0.1 steps to find highest leverage with HF >= floor + let best = 1.0; + for (let lev = maxSlider; lev >= 1.0; lev = Math.round((lev - 0.1) * 10) / 10) { + if (hfForLeverage(lev, c, l) >= floor) { best = lev; break; } + } + + slider.value = String(best); + numIn.value = best.toFixed(1); + updatePreview(); +} + +$("safe-max-btn").addEventListener("click", applySafeMax); + +// HF floor input (settings dropdown) +const hfFloorInput = $("hf-floor-input") as HTMLInputElement; +hfFloorInput.value = String(getHfFloor()); +hfFloorInput.addEventListener("change", () => { + const v = parseFloat(hfFloorInput.value); + if (isFinite(v) && v >= 1.01) setHfFloor(v); + else hfFloorInput.value = String(getHfFloor()); // revert invalid +}); + // ── Auto-reconnect saved wallet ────────────────────────────────────────────── (async () => { // Restore network preference diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..afbb1ef 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -352,10 +352,39 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 /* ── Portfolio summary ───────────────────────────────────────────────────── */ +.portfolio-summary-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 6px; +} +.portfolio-sort-btns { display: flex; gap: 4px; } +.pos-sort-btn { + padding: 2px 8px; font-size: 11px; font-weight: 600; border-radius: var(--r); + background: transparent; border: 1px solid var(--border); color: var(--text-2); + cursor: pointer; font-family: var(--sans); transition: all .15s; +} +.pos-sort-btn:hover { border-color: var(--border-h); color: var(--text); } +.pos-sort-btn.active { border-color: var(--primary); color: var(--primary); } +.sort-arrow { font-size: 10px; } + .portfolio-summary { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; margin-bottom: 14px; scrollbar-width: thin; } +.portfolio-summary.portfolio-summary-compact { display: block; } + +/* Compact table */ +.pos-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 14px; } +.pos-table th { + text-align: left; padding: 4px 8px; font-size: 10px; font-weight: 600; + text-transform: uppercase; letter-spacing: .4px; color: var(--text-3); + border-bottom: 1px solid var(--border); +} +.pos-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); } +.pos-table-row { cursor: pointer; transition: background .12s; } +.pos-table-row:hover { background: var(--surface); } +.pos-table-row.active { background: var(--tab-active-bg); } +.fw600 { font-weight: 700; } + .portfolio-card { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--r);