diff --git a/Cargo.lock b/Cargo.lock index 6afc830..6dd6a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2030,9 +2030,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", diff --git a/contracts/strategies/blend_leverage/Cargo.lock b/contracts/strategies/blend_leverage/Cargo.lock index 87f0c17..96940dc 100644 --- a/contracts/strategies/blend_leverage/Cargo.lock +++ b/contracts/strategies/blend_leverage/Cargo.lock @@ -2,6 +2,14 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "admin-sep" +version = "0.0.1" +source = "git+https://github.com/theahaco/admin-sep?rev=46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028#46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ahash" version = "0.8.12" @@ -198,10 +206,12 @@ dependencies = [ name = "blend_leverage_strategy" version = "0.1.0" dependencies = [ + "admin-sep", "blend-contract-sdk", "defindex-strategy-core", "soroban-fixed-point-math", "soroban-sdk", + "vault_share_token", ] [[package]] @@ -1637,6 +1647,14 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "vault_share_token" +version = "0.1.0" +dependencies = [ + "admin-sep", + "soroban-sdk", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/contracts/strategies/blend_leverage/Cargo.toml b/contracts/strategies/blend_leverage/Cargo.toml index 85108d8..07ca9a9 100644 --- a/contracts/strategies/blend_leverage/Cargo.toml +++ b/contracts/strategies/blend_leverage/Cargo.toml @@ -12,10 +12,12 @@ soroban-sdk = "25.3.0" defindex-strategy-core = "0.3.0" soroban-fixed-point-math = "1.3.0" blend-contract-sdk = "2.25.0" +admin-sep = { git = "https://github.com/theahaco/admin-sep", rev = "46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028" } [dev-dependencies] soroban-sdk = { version = "25.3.0", features = ["testutils"] } blend-contract-sdk = { version = "2.25.0", features = ["testutils"] } +vault_share_token = { path = "../../tokens/vault_share" } [profile.release] opt-level = "z" diff --git a/contracts/strategies/blend_leverage/src/blend_pool.rs b/contracts/strategies/blend_leverage/src/blend_pool.rs index c1044ad..097fea3 100644 --- a/contracts/strategies/blend_leverage/src/blend_pool.rs +++ b/contracts/strategies/blend_leverage/src/blend_pool.rs @@ -9,7 +9,7 @@ use soroban_sdk::{ use crate::{ constants::{ REQUEST_TYPE_BORROW, REQUEST_TYPE_REPAY, REQUEST_TYPE_SUPPLY_COLLATERAL, - REQUEST_TYPE_WITHDRAW_COLLATERAL, SCALAR_12, + REQUEST_TYPE_WITHDRAW_COLLATERAL, SCALAR_12, SCALAR_7, }, leverage::{compute_step, loop_step_count}, soroswap::internal_swap_exact_tokens_for_tokens, @@ -153,15 +153,32 @@ pub fn submit_unwind( let pre_balance = token_client.balance(&strategy); + // Blend request amounts are denominated in the UNDERLYING asset, but the + // caller passes b/d-TOKEN quantities. Convert with the current pool rates + // (underlying = tokens × rate / SCALAR_12). The two are only equal while the + // rates sit at 1.0; once interest accrues they diverge, so skipping this + // conversion makes the unwind repay/withdraw the wrong amounts. + let reserve = pool_client.get_reserve(&config.asset); + let b_rate = reserve.data.b_rate; + let d_rate = reserve.data.d_rate; + let d_underlying = d_tokens_to_remove + .checked_mul(d_rate) + .ok_or(StrategyError::ArithmeticError)? + / SCALAR_12; + let b_underlying = b_tokens_to_remove + .checked_mul(b_rate) + .ok_or(StrategyError::ArithmeticError)? + / SCALAR_12; + // Build atomic unwind: [withdraw, repay] × N steps + [withdraw equity]. - // Split d_tokens_to_remove evenly across target_loops steps. + // Split the underlying debt 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; + let repay_per_step = d_underlying / n_steps as i128; // Check if this is a full close (removing all debt) let pool_client_inner = BlendPoolClient::new(e, &config.pool); @@ -180,18 +197,15 @@ pub fn submit_unwind( 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) + d_underlying - 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) + // The equity portion (b - d, in underlying) is withdrawn separately at the end. + let withdraw_amount = if is_last { + d_underlying - repay_per_step * (n_steps as i128 - 1) } else { repay_per_step }; @@ -209,10 +223,9 @@ pub fn submit_unwind( 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); + // Final: withdraw equity portion (collateral minus debt that was removed), + // in underlying. + let equity_withdraw = b_underlying.checked_sub(d_underlying).unwrap_or(0); if equity_withdraw > 0 { requests.push_back(Request { @@ -314,44 +327,51 @@ pub fn submit_deleverage( return Ok((0, 0)); } - // Build all (withdraw, repay) pairs for a single atomic submit. - // Each layer amount = the borrow amount of the corresponding leverage step. - // Unwind in reverse order (last leverage step unwound first). - let count = loop_step_count(config.target_loops); - let mut layers: Vec = Vec::new(e); - let mut orig_balance = pre_b; // approximate with total collateral - for i in 0..count { - let is_final = i == config.target_loops.min(20); - let (_, borrow) = compute_step(orig_balance, config.c_factor, is_final); - if borrow > 0 { - layers.push_back(borrow); - } - orig_balance = borrow; + // Size one unwind layer as `debt × (1 - c_factor)`, in UNDERLYING — the same + // definition `compute_partial_unwind` uses to derive `unwind_loops`, so that + // unwinding N loops repays ≈ the intended `repay_underlying`. (The previous + // implementation seeded the layers from total collateral, producing layers + // several times larger than the position, which over-unwound or reverted.) + // Blend request amounts are denominated in the underlying asset, so convert + // the d-token debt with the current d_rate. + let reserve = pool_client.get_reserve(&config.asset); + let debt_underlying = pre_d + .checked_mul(reserve.data.d_rate) + .ok_or(StrategyError::ArithmeticError)? + / SCALAR_12; + let layer = debt_underlying + .checked_mul(SCALAR_7 - config.c_factor) + .ok_or(StrategyError::ArithmeticError)? + / SCALAR_7; + if layer <= 0 { + return Ok((0, 0)); } + // Build all (withdraw, repay) pairs for a single atomic submit, each step + // HF-neutral (withdraw == repay). Cap the cumulative repay at the outstanding + // debt so we never over-repay or withdraw more collateral than exists. let mut requests: Vec = Vec::new(e); let mut total_repay = 0i128; - let n_layers = layers.len(); - let loops_to_unwind = unwind_loops.min(n_layers); - - for i in 0..loops_to_unwind { - let idx = n_layers - 1 - i; - let layer_amount = layers.get(idx).unwrap_or(0); - if layer_amount == 0 { - continue; + let mut remaining_debt = debt_underlying; + + for _ in 0..unwind_loops.min(20) { + let amount = layer.min(remaining_debt); + if amount <= 0 { + break; } requests.push_back(Request { address: config.asset.clone(), - amount: layer_amount, + amount, request_type: REQUEST_TYPE_WITHDRAW_COLLATERAL, }); requests.push_back(Request { address: config.asset.clone(), - amount: layer_amount, + amount, request_type: REQUEST_TYPE_REPAY, }); - total_repay += layer_amount; + total_repay += amount; + remaining_debt -= amount; } if total_repay > 0 { diff --git a/contracts/strategies/blend_leverage/src/lib.rs b/contracts/strategies/blend_leverage/src/lib.rs index ab39c8c..dc0037d 100644 --- a/contracts/strategies/blend_leverage/src/lib.rs +++ b/contracts/strategies/blend_leverage/src/lib.rs @@ -21,7 +21,8 @@ mod test_integration; #[cfg(test)] mod test_leverage; -use constants::SCALAR_12; +use admin_sep::{Administratable, AdministratableExtension, Upgradable}; +use constants::{SCALAR_12, SCALAR_7}; pub use defindex_strategy_core::{event, DeFindexStrategyTrait, StrategyError}; use leverage::{ check_deposit_safety, compute_health_factor, compute_partial_unwind, compute_totals, @@ -103,6 +104,29 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { check_positive_amount(reward_threshold).expect("reward_threshold must be positive"); + // Validate risk parameters at deploy time. A misconfiguration here would + // either allow unsafe leverage or permanently brick liquidation + // protection (`compute_partial_unwind` requires orange_hf > c_factor), + // so these invariants are enforced before any funds can enter. + // + // 0 < c_factor < 1.0 — a collateral factor must be a fraction + // 1 <= target_loops <= 20 — at least one leverage loop; 20 is the + // internal request cap + // min_hf > 1.0 — never open a directly-liquidatable position + // orange_hf > min_hf — orange (rebalance) zone sits above the + // hard deposit floor + // (orange_hf > c_factor is implied by orange_hf > min_hf > 1.0 > c_factor.) + assert!( + c_factor > 0 && c_factor < SCALAR_7, + "c_factor must be in (0, 1.0)" + ); + assert!( + (1..=20).contains(&target_loops), + "target_loops must be in [1, 20]" + ); + assert!(min_hf > SCALAR_7, "min_hf must be > 1.0"); + assert!(orange_hf > min_hf, "orange_hf must be > min_hf"); + let config = Config { asset: asset.clone(), pool, @@ -119,7 +143,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { storage::set_config(&e, config); storage::set_keeper(&e, &keeper); - storage::set_admin(&e, &admin); + Self::set_admin(&e, &admin); storage::set_version(&e, 1); } @@ -289,15 +313,24 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { let token = ShareTokenClient::new(&e, &storage::get_share_token(&e)); let user_shares = token.balance(&from); - // Calculate shares to burn + proportional b/d tokens to unwind. - let (shares_to_burn, b_to_remove, d_to_remove, updated_reserves) = + // Calculate shares to burn + the intended proportional b/d tokens to + // unwind. This does NOT persist the position (see `commit_withdraw`). + let (shares_to_burn, b_to_remove, d_to_remove, _preview) = reserves::withdraw(&e, user_shares, amount, &reserves)?; // Burn the caller's shares (minter burn — from already authorized above). token.burn_by_minter(&from, &shares_to_burn); - // Execute unwind on the pool — net equity flows to `to` - blend_pool::submit_unwind(&e, b_to_remove, d_to_remove, &to, &config)?; + // Execute unwind on the pool — net equity flows to `to`. The returned + // deltas are the b/d tokens the pool *actually* removed. + let (b_removed, d_removed) = + blend_pool::submit_unwind(&e, b_to_remove, d_to_remove, &to, &config)?; + + // Persist using the measured pool deltas so stored reserves stay in + // lock-step with the real pool position (Finding ①), matching the + // measured-delta discipline of deposit/harvest/deleverage. + let updated_reserves = + reserves::commit_withdraw(&e, shares_to_burn, b_removed, d_removed, &reserves)?; let remaining_shares = user_shares .checked_sub(shares_to_burn) @@ -461,7 +494,11 @@ impl BlendLeverageStrategy { Ok(loops) } - /// Set a new keeper address. Only the current keeper can call this. + /// Set a new keeper address (keeper self-rotation). + /// + /// Gated by the *current* keeper. This is one of two ways to rotate the + /// keeper; see `admin_set_keeper` for the admin recovery path used when the + /// keeper key is lost or compromised. pub fn set_keeper(e: Env, new_keeper: Address) -> Result<(), StrategyError> { extend_instance_ttl(&e); let old_keeper = storage::get_keeper(&e); @@ -470,6 +507,18 @@ impl BlendLeverageStrategy { Ok(()) } + /// Admin recovery path to rotate the keeper (gated by the `admin-sep` admin). + /// + /// Complements `set_keeper`: the keeper rotates itself in normal operation, + /// but if the keeper key is lost or compromised the admin can install a new + /// keeper here without the old keeper's cooperation. + pub fn admin_set_keeper(e: Env, new_keeper: Address) -> Result<(), StrategyError> { + extend_instance_ttl(&e); + Self::require_admin(&e); + storage::set_keeper(&e, &new_keeper); + Ok(()) + } + /// Get the current keeper address. pub fn get_keeper(e: Env) -> Result { extend_instance_ttl(&e); @@ -502,30 +551,11 @@ impl BlendLeverageStrategy { )) } - /// Upgrade the contract WASM in place (admin-gated). - /// - /// All persistent storage (Config, Reserves, per-user VaultPos, Keeper, - /// Admin) is preserved across the upgrade, so user health factors and - /// balances are untouched — no exit/re-enter required. The version counter - /// is bumped for observability. See docs/migration-runbook.md. - pub fn upgrade(e: Env, new_wasm_hash: BytesN<32>) -> Result<(), StrategyError> { - let admin = storage::get_admin(&e); - admin.require_auth(); - e.deployer().update_current_contract_wasm(new_wasm_hash); - storage::set_version(&e, storage::get_version(&e).saturating_add(1)); - Ok(()) - } - /// Current contract version (1 at deploy, bumped on each upgrade). pub fn version(e: Env) -> u32 { storage::get_version(&e) } - /// The admin authorized to upgrade the contract and set the share token. - pub fn admin(e: Env) -> Address { - storage::get_admin(&e) - } - /// Set the SEP-41 vault-share token (admin-gated, one-time wiring). /// /// The token must already be deployed with this strategy as its minter. @@ -533,7 +563,7 @@ impl BlendLeverageStrategy { /// per-user ledger from day one; for an upgraded legacy deployment, call /// `migrate_position` per holder afterwards. pub fn set_share_token(e: Env, token: Address) -> Result<(), StrategyError> { - storage::get_admin(&e).require_auth(); + Self::require_admin(&e); storage::set_share_token(&e, &token); extend_instance_ttl(&e); Ok(()) @@ -577,7 +607,7 @@ impl BlendLeverageStrategy { /// Set the keeper-controlled account allowed to pull claimed BLND for an /// off-chain swap (admin-gated). pub fn set_swap_account(e: Env, account: Address) -> Result<(), StrategyError> { - storage::get_admin(&e).require_auth(); + Self::require_admin(&e); storage::set_swap_account(&e, &account); extend_instance_ttl(&e); Ok(()) @@ -684,3 +714,25 @@ impl BlendLeverageStrategy { Ok(realized) } } + +// ── Administration (admin-sep SEP) ─────────────────────────────────────────── +// +// Admin storage and the `admin` / `set_admin` entrypoints come from the +// `admin-sep` Administratable trait (admin lives in instance storage under the +// SEP's canonical key). `set_admin` is current-admin-gated, giving the missing +// admin-rotation path. The constructor seeds the admin via `Self::set_admin`. + +#[contractimpl(contracttrait)] +impl Administratable for BlendLeverageStrategy {} + +// `upgrade` is admin-gated by the SEP; we override the default only to keep the +// monotonic `version` counter bumping for observability (see migration-runbook). +#[contractimpl(contracttrait)] +impl Upgradable for BlendLeverageStrategy { + fn upgrade(e: &Env, new_wasm_hash: &BytesN<32>) { + Self::require_admin(e); + e.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + storage::set_version(e, storage::get_version(e).saturating_add(1)); + } +} diff --git a/contracts/strategies/blend_leverage/src/reserves.rs b/contracts/strategies/blend_leverage/src/reserves.rs index 08f83af..5862bf1 100644 --- a/contracts/strategies/blend_leverage/src/reserves.rs +++ b/contracts/strategies/blend_leverage/src/reserves.rs @@ -117,13 +117,20 @@ pub fn deposit( /// 2. Calculate proportional b/d tokens /// 3. Update totals /// -/// Returns `(shares_to_burn, b_tokens_to_remove, d_tokens_to_remove, updated_reserves)`. +/// Returns `(shares_to_burn, b_tokens_to_remove, d_tokens_to_remove, preview_reserves)`. /// /// `user_shares` is the caller's current token balance (read by `lib.rs` from /// the share token, not from strategy storage). This no longer writes /// `VaultPos`: the caller burns `shares_to_burn` from the token. +/// +/// IMPORTANT: this function does **not** persist the position. The returned +/// `b/d_tokens_to_remove` are the *intended* unwind amounts (fed to +/// `submit_unwind`); the `preview_reserves` are the corresponding projected +/// state and are exact only when token≈underlying. The real reserves are +/// committed by `commit_withdraw` from the pool's *measured* deltas, so stored +/// reserves stay in lock-step with the actual pool position (Finding ①). pub fn withdraw( - e: &Env, + _e: &Env, user_shares: i128, amount: i128, // underlying amount requested reserves: &LeverageReserves, @@ -156,7 +163,8 @@ pub fn withdraw( .fixed_mul_floor(reserves.total_d_tokens, reserves.total_shares) .ok_or(StrategyError::ArithmeticError)?; - // Update totals + // Project the post-withdraw state for the caller's preview/return value. + // NOTE: not persisted here — see `commit_withdraw` (Finding ①). reserves.total_shares = reserves .total_shares .checked_sub(shares_to_burn) @@ -170,9 +178,6 @@ pub fn withdraw( .checked_sub(d_tokens_to_remove) .ok_or(StrategyError::UnderflowOverflow)?; - // Persist - storage::set_strategy_reserves(e, reserves.clone()); - Ok(( shares_to_burn, b_tokens_to_remove, @@ -181,6 +186,38 @@ pub fn withdraw( )) } +/// Commit a withdrawal to storage using the b/d tokens the pool *actually* +/// removed (returned by `submit_unwind`), keeping stored reserves in lock-step +/// with the real pool position — the same measured-delta discipline used by +/// `deposit`, `harvest` and `deleverage`. +/// +/// `reserves` is the pre-withdraw snapshot (with refreshed rates). `shares_to_burn` +/// is the amount burned from the share token; `b_removed`/`d_removed` are the +/// measured pool deltas. Position totals use `saturating_sub` so a full close +/// that clears a stroop more than was tracked (e.g. the `i64::MAX` dust sweep) +/// floors at zero instead of reverting. +/// +/// Returns the persisted, post-withdraw reserves. +pub fn commit_withdraw( + e: &Env, + shares_to_burn: i128, + b_removed: i128, + d_removed: i128, + reserves: &LeverageReserves, +) -> Result { + let mut reserves = reserves.clone(); + + reserves.total_shares = reserves + .total_shares + .checked_sub(shares_to_burn) + .ok_or(StrategyError::UnderflowOverflow)?; + reserves.total_b_tokens = reserves.total_b_tokens.saturating_sub(b_removed); + reserves.total_d_tokens = reserves.total_d_tokens.saturating_sub(d_removed); + + storage::set_strategy_reserves(e, reserves.clone()); + Ok(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 5547578..c885260 100644 --- a/contracts/strategies/blend_leverage/src/storage.rs +++ b/contracts/strategies/blend_leverage/src/storage.rs @@ -19,8 +19,6 @@ pub enum DataKey { Reserves, VaultPos(Address), Keeper, - /// Admin authorized to upgrade the contract WASM and set the share token. - Admin, /// Monotonic contract version, bumped on each upgrade. Version, /// The SEP-41 vault-share token contract — the canonical per-user share @@ -151,17 +149,9 @@ pub fn get_keeper(e: &Env) -> Address { } // ── Admin ──────────────────────────────────────────────────────────────────── - -pub fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -pub fn get_admin(e: &Env) -> Address { - e.storage() - .instance() - .get(&DataKey::Admin) - .expect("Admin not set") -} +// +// Admin storage now lives in the `admin-sep` Administratable trait (see lib.rs), +// under the SEP's canonical instance-storage key — there is no local Admin key. // ── Version ────────────────────────────────────────────────────────────────── diff --git a/contracts/strategies/blend_leverage/src/test_integration.rs b/contracts/strategies/blend_leverage/src/test_integration.rs index 6878e80..e42e6d3 100644 --- a/contracts/strategies/blend_leverage/src/test_integration.rs +++ b/contracts/strategies/blend_leverage/src/test_integration.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - //! Integration tests against a real mock Blend pool using BlendFixture. //! //! Tests pool interactions (supply, borrow, repay, withdraw) individually, @@ -18,7 +16,7 @@ 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, IntoVal, String, Val, Vec, }; @@ -27,7 +25,9 @@ use crate::constants::{ REQUEST_TYPE_BORROW, REQUEST_TYPE_REPAY, REQUEST_TYPE_SUPPLY_COLLATERAL, REQUEST_TYPE_WITHDRAW_COLLATERAL, SCALAR_12, SCALAR_7, }; -use crate::leverage::{compute_health_factor, compute_loop_pairs, shares_to_underlying}; +use crate::leverage::{ + compute_health_factor, compute_loop_pairs, compute_partial_unwind, shares_to_underlying, +}; use crate::storage::LeverageReserves; use crate::{blend_pool, reserves, storage}; @@ -852,6 +852,25 @@ fn test_share_token_wiring_set_migrate_balance() { assert!(bal > 0, "balance via token should be positive, got {}", bal); } +// Admin recovery path: the admin can rotate the keeper via `admin_set_keeper` +// without the old keeper's cooperation (second of the two rotation routes). +#[test] +fn test_admin_set_keeper_rotates_keeper() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let strategy = register_real_strategy(&e, &pool_addr, &token, &blnd); + let sclient = crate::BlendLeverageStrategyClient::new(&e, &strategy); + + let new_keeper = Address::generate(&e); + sclient.admin_set_keeper(&new_keeper); + assert_eq!( + sclient.get_keeper(), + new_keeper, + "admin must be able to recover/rotate the keeper" + ); +} + // ── Auto-rebalance keeper auth & rate-limit (T2.3) ──────────────────────────── #[test] @@ -917,6 +936,486 @@ fn test_split_harvest_rejects_non_keeper() { ); } +// ── Regression test: unwind must pay the correct equity after rates accrue ──── +// +// Bug #1 (b/d-token ↔ underlying unit confusion). `reserves::withdraw` returns +// proportional b/d-TOKEN quantities; `blend_pool::submit_unwind` feeds them +// straight into Blend `Request.amount`, which Blend reads as UNDERLYING. Tokens +// equal underlying ONLY when b_rate == d_rate == SCALAR_12 (what every other +// test pins). Once Blend interest accrues, withdrawing X equity pays out the +// WRONG amount of underlying, draining the vault. +// +// This test exercises the REAL production `submit_unwind` against the REAL Blend +// pool after ~1 year of interest, and asserts the withdrawing user receives the +// equity they are actually owed. It FAILS until `submit_unwind` converts token +// amounts to underlying (× rate / SCALAR_12). +#[test] +fn test_unwind_pays_correct_equity_after_rates_accrue() { + 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, 1_000_000_0000000); + + let strategy = e.register(TestStrategyContract, ()); + let user = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let token_client = TokenClient::new(&e, &token); + + let deposit = 1_000_0000000_i128; + token_admin.mint(&strategy, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + // Build the leveraged position (3 loops at c=0.90). + let (b_tokens, d_tokens) = execute_leverage_loop_stepped( + &e, + &pool_addr, + &strategy, + &token, + deposit, + config.c_factor, + config.target_loops, + ); + + // Seed reserves to match (1 share == 1 underlying at entry, rates 1.0). + e.as_contract(&strategy, || { + storage::set_strategy_reserves( + &e, + LeverageReserves { + total_shares: deposit, + total_b_tokens: b_tokens, + total_d_tokens: d_tokens, + b_rate: SCALAR_12, + d_rate: SCALAR_12, + }, + ); + }); + + // Advance ~1 year so Blend interest accrues (rates drift above 1.0). + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + // Poke the reserve so the accrual is materialised in the stored rates. + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + assert!( + d_rate > SCALAR_12, + "precondition: interest must accrue (d_rate={})", + d_rate + ); + + // Compute a 25%-equity withdrawal using the accrued rates, exactly like + // production: shares→(b_to_remove, d_to_remove) token quantities. + let (requested, b_to_remove, d_to_remove) = e.as_contract(&strategy, || { + let reserves = reserves::get_strategy_reserves_updated(&e, &config); + let equity = crate::leverage::compute_equity(&reserves).unwrap(); + let requested = equity / 4; + let (_burned, b_rm, d_rm, _updated) = + reserves::withdraw(&e, reserves.total_shares, requested, &reserves).unwrap(); + (requested, b_rm, d_rm) + }); + + let user_before = token_client.balance(&user); + + // Run the REAL production unwind against the REAL pool. + e.as_contract(&strategy, || { + blend_pool::submit_unwind(&e, b_to_remove, d_to_remove, &user, &config).unwrap(); + }); + + let received = token_client.balance(&user) - user_before; + + std::println!( + "b_rate={} d_rate={} | requested(owed)={} received={} (b_rm={}, d_rm={})", + b_rate, + d_rate, + requested, + received, + b_to_remove, + d_to_remove + ); + + // The user must receive the equity they actually own — no more, no less. + // Allow 1% tolerance for pool/loop rounding. FAILS today because the unwind + // pays out ~ (b_to_remove - d_to_remove) of underlying, materially above the + // owed equity once rates have accrued. + let tolerance = requested / 100; + assert!( + (received - requested).abs() <= tolerance, + "withdrawing user should receive ~{} (owed equity) but got {} (diff {})", + requested, + received, + (received - requested).abs() + ); +} + +// Full-close sibling of the regression test above: withdrawing the ENTIRE +// position after interest has accrued must (a) pay the user their full equity +// and (b) leave no collateral/debt stranded in the pool. Before the unit- +// confusion fix, the unwind under-withdrew collateral (token counts used as +// underlying), leaving dust locked in the pool. +#[test] +fn test_full_close_returns_all_equity_after_rates_accrue() { + 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, 1_000_000_0000000); + + let strategy = e.register(TestStrategyContract, ()); + let user = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let token_client = TokenClient::new(&e, &token); + + let deposit = 1_000_0000000_i128; + token_admin.mint(&strategy, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + let (b_tokens, d_tokens) = execute_leverage_loop_stepped( + &e, + &pool_addr, + &strategy, + &token, + deposit, + config.c_factor, + config.target_loops, + ); + + // Advance ~1 year and poke the reserve so interest is materialised. + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + assert!(d_rate > SCALAR_12, "precondition: interest must accrue"); + + // Equity owed for a FULL close, in underlying. + let owed = b_tokens * b_rate / SCALAR_12 - d_tokens * d_rate / SCALAR_12; + + let user_before = token_client.balance(&user); + e.as_contract(&strategy, || { + blend_pool::submit_unwind(&e, b_tokens, d_tokens, &user, &config).unwrap(); + }); + let received = token_client.balance(&user) - user_before; + + // Position should be essentially emptied. + let end = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let end_b = end.collateral.get(config.reserve_id).unwrap_or(0); + let end_d = end.liabilities.get(config.reserve_id).unwrap_or(0); + + std::println!( + "owed={} received={} end_b={} end_d={}", + owed, + received, + end_b, + end_d + ); + + // User gets their full equity (1% tolerance for pool/loop rounding). + assert!( + (received - owed).abs() <= owed / 100, + "full close should return all equity ~{}, got {}", + owed, + received + ); + // No material collateral left stranded (≤ 0.5% of the original collateral). + assert!( + end_b <= b_tokens / 200, + "collateral left stranded in pool: end_b={} (started {})", + end_b, + b_tokens + ); + // All debt cleared. + assert!(end_d == 0, "debt not fully cleared: end_d={}", end_d); +} + +// Coverage for the production `submit_deleverage` path (used by rebalance / +// partial_unwind) at accrued rates — the third site of the unit-confusion fix. +// Deleveraging must reduce both collateral and debt, improve the health factor, +// and preserve equity (each layer withdraws == repays the same underlying). +#[test] +fn test_deleverage_improves_hf_and_preserves_equity_after_rates_accrue() { + 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, 1_000_000_0000000); + + let strategy = e.register(TestStrategyContract, ()); + let token_admin = StellarAssetClient::new(&e, &token); + + let deposit = 1_000_0000000_i128; + token_admin.mint(&strategy, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + execute_leverage_loop_stepped( + &e, + &pool_addr, + &strategy, + &token, + deposit, + config.c_factor, + config.target_loops, + ); + + // Advance ~1 year and poke the reserve so interest is materialised. + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + assert!(d_rate > SCALAR_12, "precondition: interest must accrue"); + + let pre = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let pre_b = pre.collateral.get(config.reserve_id).unwrap_or(0); + let pre_d = pre.liabilities.get(config.reserve_id).unwrap_or(0); + let pre_equity = pre_b * b_rate / SCALAR_12 - pre_d * d_rate / SCALAR_12; + let pre_hf = compute_health_factor(pre_b, pre_d, b_rate, d_rate, config.c_factor).unwrap(); + + // Unwind 2 loops through the REAL production deleverage path. + let (b_removed, d_removed) = e.as_contract(&strategy, || { + blend_pool::submit_deleverage(&e, 2, &config).unwrap() + }); + + let post = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let post_b = post.collateral.get(config.reserve_id).unwrap_or(0); + let post_d = post.liabilities.get(config.reserve_id).unwrap_or(0); + let post_equity = post_b * b_rate / SCALAR_12 - post_d * d_rate / SCALAR_12; + let post_hf = compute_health_factor(post_b, post_d, b_rate, d_rate, config.c_factor).unwrap(); + + std::println!( + "b_removed={} d_removed={} pre_hf={} post_hf={} pre_eq={} post_eq={}", + b_removed, + d_removed, + pre_hf, + post_hf, + pre_equity, + post_equity + ); + + // Deleveraging reduces both sides of the position. + assert!( + b_removed > 0 && d_removed > 0, + "should remove collateral and debt: b_removed={}, d_removed={}", + b_removed, + d_removed + ); + assert!( + post_d < pre_d, + "debt must decrease: pre={}, post={}", + pre_d, + post_d + ); + + // Reducing leverage improves the health factor. + assert!( + post_hf > pre_hf, + "HF must improve after deleverage: pre={}, post={}", + pre_hf, + post_hf + ); + + // Equity is preserved (each layer withdraws == repays the same underlying); + // allow 1% for pool/loop rounding. + assert!( + (post_equity - pre_equity).abs() <= pre_equity / 100, + "equity must be preserved: pre={}, post={}", + pre_equity, + post_equity + ); +} + +// A single-loop deleverage must be a PARTIAL unwind, not a full close. With the +// broken layer sizing, one "layer" exceeds the whole debt, so a 1-loop unwind +// either reverts or repays the entire position — defeating partial protection. +#[test] +fn test_deleverage_one_loop_is_partial_not_full_close() { + 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, 1_000_000_0000000); + + let strategy = e.register(TestStrategyContract, ()); + let token_admin = StellarAssetClient::new(&e, &token); + + let deposit = 1_000_0000000_i128; + token_admin.mint(&strategy, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + execute_leverage_loop_stepped( + &e, + &pool_addr, + &strategy, + &token, + deposit, + config.c_factor, + config.target_loops, + ); + + let pre = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let pre_d = pre.liabilities.get(config.reserve_id).unwrap_or(0); + + // Unwind exactly ONE loop through the real production path. + e.as_contract(&strategy, || { + blend_pool::submit_deleverage(&e, 1, &config).unwrap(); + }); + + let post = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let post_d = post.liabilities.get(config.reserve_id).unwrap_or(0); + + std::println!("pre_d={} post_d={}", pre_d, post_d); + + // A single-loop unwind should clear only one layer (~debt × (1-c) ≈ 10%), + // so the bulk of the debt must remain. FAILS today: the oversized layer + // wipes (or over-shoots) the whole debt. + assert!( + post_d > 0, + "1-loop unwind should not fully close the position" + ); + assert!( + post_d >= pre_d / 2, + "1-loop unwind must be partial: pre_d={}, post_d={} (over-unwound)", + pre_d, + post_d + ); +} + +// End-to-end protection round-trip (mirrors lib.rs::unwind_to): a position that +// sits in the orange zone (HF < orange_hf) must be restored to >= orange_hf by +// `compute_partial_unwind` → `submit_deleverage`. This proves the two pieces are +// consistent: the loop count derived from the closed form actually achieves the +// target HF on the real pool. +#[test] +fn test_rebalance_round_trip_restores_hf_to_target() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let mut config = make_config(&e, &pool_addr, &token, &blnd); + // High leverage so the freshly-built position starts inside the orange zone. + config.target_loops = 8; + + seed_pool_liquidity(&e, &pool_addr, &token, 1_000_000_0000000); + + let strategy = e.register(TestStrategyContract, ()); + let token_admin = StellarAssetClient::new(&e, &token); + let deposit = 1_000_0000000_i128; + token_admin.mint(&strategy, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + execute_leverage_loop_stepped( + &e, + &pool_addr, + &strategy, + &token, + deposit, + config.c_factor, + config.target_loops, + ); + + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + let pre = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let b = pre.collateral.get(config.reserve_id).unwrap_or(0); + let d = pre.liabilities.get(config.reserve_id).unwrap_or(0); + + let target = config.orange_hf; + let before_hf = compute_health_factor(b, d, b_rate, d_rate, config.c_factor).unwrap(); + assert!( + before_hf < target, + "fixture must start in the orange zone: before_hf={}, target={}", + before_hf, + target + ); + + // Production logic: derive the loop count needed to restore HF to target. + let (_, loops) = compute_partial_unwind(b, d, b_rate, d_rate, config.c_factor, target).unwrap(); + assert!(loops >= 1, "should need at least one unwind loop"); + + // Execute the real deleverage on the real pool. + e.as_contract(&strategy, || { + blend_pool::submit_deleverage(&e, loops, &config).unwrap(); + }); + + let post = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let b2 = post.collateral.get(config.reserve_id).unwrap_or(0); + let d2 = post.liabilities.get(config.reserve_id).unwrap_or(0); + let after_hf = compute_health_factor(b2, d2, b_rate, d_rate, config.c_factor).unwrap(); + + std::println!( + "before_hf={} after_hf={} target={} loops={}", + before_hf, + after_hf, + target, + loops + ); + + // HF restored to at least the target … + assert!( + after_hf >= target, + "HF must be restored to >= target: after_hf={}, target={}", + after_hf, + target + ); + // … without grossly over-unwinding (ceil rounding adds at most ~one layer). + assert!( + after_hf <= target + target * 30 / 100, + "over-unwound: after_hf={}, target={}", + after_hf, + target + ); +} + #[test] fn test_harvest_reinvest_soroswap_requires_min_out() { let e = Env::default(); @@ -934,3 +1433,517 @@ fn test_harvest_reinvest_soroswap_requires_min_out() { "soroswap path requires non-zero amount_out_min" ); } + +// ── Finding ①: withdraw must keep stored reserves in sync with the pool ─────── +// +// The strategy keeps TWO ledgers of the same position: +// A) stored `LeverageReserves.total_b/d_tokens` — values shares (deposit / +// withdraw / balance / position) +// B) the real `pool.get_positions(strategy)` — drives HF / rebalance +// +// `deposit`, `harvest` and `deleverage` all update A from the *measured* pool +// delta, so A == B by construction. `withdraw` used to be the lone exception: it +// subtracted the *intended* `b_to_remove`/`d_to_remove` (proportional token +// counts) and DISCARDED the actual amounts `submit_unwind` removed, so A drifted +// away from B and never reconciled. The fix routes the withdraw through +// `reserves::commit_withdraw`, persisting the *measured* (b_removed, d_removed) +// just like the other three paths. +// +// End-to-end guard for Finding ①, driven through the REAL `deposit`/`withdraw` +// contract entrypoints (not the internal helpers). This is the test that +// actually protects the production code path: it FAILS if lib.rs::withdraw is +// reverted to subtract the intended deltas instead of committing the measured +// ones. Uses the real strategy + real Blend pool + a mock share token. +#[test] +fn test_real_withdraw_entrypoint_keeps_reserves_in_sync() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let cfg = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 1_000_000_0000000); + + let strategy = register_real_strategy(&e, &pool_addr, &token, &blnd); + let sclient = crate::BlendLeverageStrategyClient::new(&e, &strategy); + + // Wire the share token (the strategy is its minter). + let share = e.register(MockShareToken, ()); + sclient.set_share_token(&share); + + // Fund a user and deposit through the REAL entrypoint (runs the real + // submit_leverage_loop + reserves::deposit reconciliation). + let user = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let deposit = 1_000_0000000_i128; + token_admin.mint(&user, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + sclient.deposit(&deposit, &user); + + // Post-deposit, stored reserves should already equal pool positions. + let after_dep = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let dep_b = after_dep.collateral.get(cfg.reserve_id).unwrap_or(0); + let dep_d = after_dep.liabilities.get(cfg.reserve_id).unwrap_or(0); + let stored_dep = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + assert_eq!(stored_dep.total_b_tokens, dep_b, "post-deposit b in sync"); + assert_eq!(stored_dep.total_d_tokens, dep_d, "post-deposit d in sync"); + + // Accrue ~1 year and poke the reserve so rates drift above 1.0. + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + // Withdraw ~30% of the user's balance through the REAL entrypoint, twice. + for _ in 0..2 { + let bal = sclient.balance(&user); + let amount = bal * 3 / 10; + sclient.withdraw(&amount, &user, &user); + } + + // The invariant must hold through the production withdraw path. + let post = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let pool_b = post.collateral.get(cfg.reserve_id).unwrap_or(0); + let pool_d = post.liabilities.get(cfg.reserve_id).unwrap_or(0); + let stored = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + + std::println!( + "e2e post-withdraw: stored_b={} pool_b={} (diff={}) | stored_d={} pool_d={} (diff={})", + stored.total_b_tokens, + pool_b, + stored.total_b_tokens - pool_b, + stored.total_d_tokens, + pool_d, + stored.total_d_tokens - pool_d, + ); + + assert_eq!( + stored.total_b_tokens, pool_b, + "post-withdraw b out of sync with pool" + ); + assert_eq!( + stored.total_d_tokens, pool_d, + "post-withdraw d out of sync with pool" + ); +} + +// Full-close sibling of the e2e guard: a user withdrawing their ENTIRE balance +// drives the near-full unwind (large repay + the `i64::MAX` dust sweep) and the +// `commit_withdraw` `saturating_sub`. Asserts the stored==pool invariant still +// holds, the user is paid ~their full balance, and only the inflation lockup is +// left behind. +#[test] +fn test_real_full_withdraw_entrypoint_keeps_reserves_in_sync() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + let cfg = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 1_000_000_0000000); + + let strategy = register_real_strategy(&e, &pool_addr, &token, &blnd); + let sclient = crate::BlendLeverageStrategyClient::new(&e, &strategy); + let share = e.register(MockShareToken, ()); + sclient.set_share_token(&share); + let shclient = MockShareTokenClient::new(&e, &share); + + let user = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let token_client = TokenClient::new(&e, &token); + let deposit = 1_000_0000000_i128; + token_admin.mint(&user, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + sclient.deposit(&deposit, &user); + + // Accrue ~1 year and poke so rates drift above 1.0. + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + // Withdraw the user's ENTIRE reported balance through the real entrypoint. + let bal = sclient.balance(&user); + let user_before = token_client.balance(&user); + sclient.withdraw(&bal, &user, &user); + let received = token_client.balance(&user) - user_before; + + // Invariant still holds across the near-full unwind (saturating_sub path). + let post = pool::Client::new(&e, &pool_addr).get_positions(&strategy); + let pool_b = post.collateral.get(cfg.reserve_id).unwrap_or(0); + let pool_d = post.liabilities.get(cfg.reserve_id).unwrap_or(0); + let stored = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + + std::println!( + "full-close e2e: received={} (bal={}) | stored_b={} pool_b={} | stored_d={} pool_d={} | total_shares={} user_shares_left={}", + received, + bal, + stored.total_b_tokens, + pool_b, + stored.total_d_tokens, + pool_d, + stored.total_shares, + shclient.balance(&user), + ); + + assert_eq!( + stored.total_b_tokens, pool_b, + "full-close: stored b out of sync with pool" + ); + assert_eq!( + stored.total_d_tokens, pool_d, + "full-close: stored d out of sync with pool" + ); + + // The user is paid ~their whole balance (1% tolerance for pool/loop rounding). + assert!( + (received - bal).abs() <= bal / 100, + "user should receive ~full balance: got {} want {}", + received, + bal + ); + + // Only the inflation lockup (held by the strategy) remains; the user's own + // shares are essentially gone (allow a few stroops of ceil/floor dust). + assert!( + shclient.balance(&user) <= 1_000, + "user shares should be ~fully burned, left {}", + shclient.balance(&user) + ); + assert!( + (stored.total_shares - crate::constants::FIRST_DEPOSIT_LOCKUP).abs() <= 1_000, + "only the lockup should remain, total_shares={}", + stored.total_shares + ); +} + +// ── D2: a transferred receipt token carries the underlying claim ────────────── +// +// The vault-share token is a standard SEP-41 — holding it *is* holding the +// position. This drives the full chain through the REAL token contract (not the +// MockShareToken): real SEP-41 `transfer` semantics + real strategy + real +// Blend pool. Alice deposits, transfers her entire share balance to Bob, then +// Bob — who never touched the strategy — withdraws and is paid the underlying, +// while Alice is paid nothing. This is the integration guarantee the Aquarius +// listing (T3) relies on: the strategy attributes equity by *current* token +// ownership, and the token supply stays equal to the strategy's `total_shares`. +#[test] +fn test_transferred_shares_let_recipient_withdraw() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + // Config must exist for the pool reserve, but this test reads claims through + // the public strategy entrypoints rather than stored reserves directly. + let _cfg = make_config(&e, &pool_addr, &token, &blnd); + + seed_pool_liquidity(&e, &pool_addr, &token, 1_000_000_0000000); + + let strategy = register_real_strategy(&e, &pool_addr, &token, &blnd); + let sclient = crate::BlendLeverageStrategyClient::new(&e, &strategy); + + // Wire the REAL SEP-41 share token, with the strategy as its sole minter. + let share = e.register( + vault_share_token::VaultShareToken, + ( + Address::generate(&e), // admin + strategy.clone(), // minter = the strategy + 7u32, + String::from_str(&e, "BlendLeverage USDC Share"), + String::from_str(&e, "blvUSDC"), + ), + ); + sclient.set_share_token(&share); + let shclient = vault_share_token::VaultShareTokenClient::new(&e, &share); + + // Alice deposits through the real entrypoint (mints her shares on the token). + let alice = Address::generate(&e); + let bob = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let token_client = TokenClient::new(&e, &token); + let deposit = 1_000_0000000_i128; + token_admin.mint(&alice, &deposit); + e.cost_estimate().budget().reset_unlimited(); + + sclient.deposit(&deposit, &alice); + + let alice_shares = shclient.balance(&alice); + assert!(alice_shares > 0, "alice should hold shares after deposit"); + assert_eq!(shclient.balance(&bob), 0, "bob starts with no shares"); + + // Accrue ~1 year and poke the reserve so rates drift above 1.0 (equity grows + // beyond principal — the post-transfer claim is non-trivial). + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + // Alice transfers her ENTIRE share balance to Bob via the SEP-41 `transfer`. + shclient.transfer(&alice, &bob, &alice_shares); + assert_eq!(shclient.balance(&alice), 0, "alice fully transferred out"); + assert_eq!( + shclient.balance(&bob), + alice_shares, + "bob now holds the shares" + ); + + // The strategy must now attribute the position to BOB, not Alice. + assert_eq!( + sclient.balance(&alice), + 0, + "alice has no claim post-transfer" + ); + let bob_claim = sclient.balance(&bob); + assert!( + bob_claim > 0, + "bob's transferred shares carry the underlying claim" + ); + + // Bob — who never deposited — withdraws his full balance and is paid. + let bob_before = token_client.balance(&bob); + sclient.withdraw(&bob_claim, &bob, &bob); + let bob_received = token_client.balance(&bob) - bob_before; + let alice_received = token_client.balance(&alice); + + std::println!( + "transfer-then-withdraw: alice_shares={} bob_claim={} bob_received={} alice_received={}", + alice_shares, + bob_claim, + bob_received, + alice_received, + ); + + // Bob receives ~his claim (1% tolerance for pool/loop rounding); Alice none. + assert!( + (bob_received - bob_claim).abs() <= bob_claim / 100, + "bob should receive ~his claim: got {} want {}", + bob_received, + bob_claim + ); + assert_eq!( + alice_received, 0, + "alice must not be paid after transferring her shares away" + ); + + // Bob's shares are ~fully burned; the token supply still equals the + // strategy's accounting (`total_supply == total_shares`), with only the + // inflation lockup left behind. + assert!( + shclient.balance(&bob) <= 1_000, + "bob shares should be ~fully burned, left {}", + shclient.balance(&bob) + ); + let stored = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + assert_eq!( + shclient.total_supply(), + stored.total_shares, + "token supply must stay equal to strategy total_shares" + ); +} + +// ── T1.3: in-place WASM upgrade parity on a LIVE Blend pool-state fixture ────── +// +// The deliverable requires the in-place WASM upgrade to preserve each user's +// health factor and balance "against live pool-state fixtures, within 1e-7". +// Unlike the seeded unit fixture in `test_leverage.rs` +// (`test_upgrade_preserves_hf_and_balance_parity`), this drives a REAL leveraged +// position on the BlendFixture pool — a real `deposit` plus a year of accrued, +// drifted b/d rates — snapshots equity / HF / per-user underlying through the +// production entrypoints, then invokes the REAL `upgrade()` entrypoint +// (admin-gated, version bump). `upgrade()` calls `update_current_contract_wasm`, +// which the test host rejects unless the target hash is a genuinely uploaded +// WASM, so we upload a real Soroban WASM to satisfy the in-place swap. After the +// swap the strategy's executable points at the new code, so post-upgrade state +// is read host-side from the *preserved* persistent storage and recomputed with +// the same production functions the entrypoints use. Parity must hold within +// 1e-7 (it is exact: an in-place WASM swap never touches storage). +fn assert_within_1e7(before: i128, after: i128, label: &str) { + let tol = (before.abs() / 10_000_000).max(1); + assert!( + (after - before).abs() <= tol, + "{label} parity beyond 1e-7: before={before} after={after} tol={tol}" + ); +} + +#[test] +fn test_upgrade_preserves_hf_and_balance_on_live_pool_state() { + let e = Env::default(); + e.mock_all_auths(); + let (pool_addr, token, blnd, _blend, _deployer) = setup_blend_env(&e); + + seed_pool_liquidity(&e, &pool_addr, &token, 1_000_000_0000000); + + let strategy = register_real_strategy(&e, &pool_addr, &token, &blnd); + let sclient = crate::BlendLeverageStrategyClient::new(&e, &strategy); + + // Real SEP-41 share token, with the strategy as its sole minter. + let share = e.register( + vault_share_token::VaultShareToken, + ( + Address::generate(&e), // admin + strategy.clone(), // minter = the strategy + 7u32, + String::from_str(&e, "BlendLeverage USDC Share"), + String::from_str(&e, "blvUSDC"), + ), + ); + sclient.set_share_token(&share); + let shclient = vault_share_token::VaultShareTokenClient::new(&e, &share); + + // Real leveraged deposit -> live pool position. + let user = Address::generate(&e); + let token_admin = StellarAssetClient::new(&e, &token); + let deposit = 1_000_0000000_i128; + token_admin.mint(&user, &deposit); + e.cost_estimate().budget().reset_unlimited(); + sclient.deposit(&deposit, &user); + + // Accrue ~1 year and poke so b/d rates drift above 1.0 — a genuine, + // non-trivial live fixture (not seeded reserves pinned at rate == 1.0). + e.ledger().with_mut(|li| { + li.timestamp += 31_536_000; + li.sequence_number += 6_000_000; + }); + let poker = Address::generate(&e); + token_admin.mint(&poker, &1_0000000); + pool::Client::new(&e, &pool_addr).submit( + &poker, + &poker, + &poker, + &vec![ + &e, + pool::Request { + address: token.clone(), + amount: 1_0000000, + request_type: REQUEST_TYPE_SUPPLY_COLLATERAL, + }, + ], + ); + + // ── Pre-upgrade snapshot via the production entrypoints ── + let (equity_before, _shares_before, _b_before, d_before, b_rate_before, d_rate_before) = + sclient.position(); + let hf_before = sclient.health_factor(); + let user_underlying_before = sclient.balance(&user); + let version_before = sclient.version(); + let user_shares = shclient.balance(&user); + let stored_before = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + + // The fixture must be a real, leveraged, rate-drifted position. + assert!(d_before > 0, "fixture must carry debt (leverage active)"); + assert!( + equity_before > 0 && hf_before > 0 && user_underlying_before > 0, + "fixture must be a non-trivial live position" + ); + assert!( + b_rate_before != SCALAR_12 || d_rate_before != SCALAR_12, + "rates must have drifted off 1.0 — proves a live pool fixture, not seeded" + ); + + // ── Invoke the REAL upgrade() entrypoint ── + let new_wasm = e + .deployer() + .upload_contract_wasm(blend_contract_sdk::pool::WASM); + sclient.upgrade(&new_wasm); + + // ── Post-upgrade recomputation from PRESERVED storage (host-side) ── + // The executable now points at the swapped WASM, so we recompute with the + // same production functions the entrypoints call, over the untouched + // persistent storage and unchanged pool state. + let version_after = e.as_contract(&strategy, || storage::get_version(&e)); + let stored_after = e.as_contract(&strategy, || storage::get_strategy_reserves(&e)); + let (equity_after, hf_after, user_underlying_after) = e.as_contract(&strategy, || { + let config = storage::get_config(&e); + let r = reserves::get_strategy_reserves_updated(&e, &config); + let equity = crate::leverage::compute_equity(&r).unwrap(); + let (b_rate, d_rate) = blend_pool::get_rates(&e, &config); + let (b_tokens, d_tokens) = blend_pool::get_strategy_positions(&e, &config); + let hf = + compute_health_factor(b_tokens, d_tokens, b_rate, d_rate, config.c_factor).unwrap(); + let underlying = shares_to_underlying(user_shares, &r).unwrap(); + (equity, hf, underlying) + }); + + // Version bumped by exactly 1; all persisted reserves byte-identical. + assert_eq!( + version_after, + version_before + 1, + "version must bump on upgrade" + ); + assert_eq!( + stored_after.total_shares, stored_before.total_shares, + "total_shares preserved across upgrade" + ); + assert_eq!( + stored_after.total_b_tokens, stored_before.total_b_tokens, + "b-tokens preserved across upgrade" + ); + assert_eq!( + stored_after.total_d_tokens, stored_before.total_d_tokens, + "d-tokens preserved across upgrade" + ); + + // HF and per-user balance identical within 1e-7 (here: exactly equal). + assert_within_1e7(equity_before, equity_after, "equity"); + assert_within_1e7(hf_before, hf_after, "health factor"); + assert_within_1e7( + user_underlying_before, + user_underlying_after, + "user underlying", + ); + + std::println!( + "upgrade-parity (live pool): v{}->v{} | hf={} | equity={} | user_underlying={} | rates b={} d={}", + version_before, + version_after, + hf_before, + equity_before, + user_underlying_before, + b_rate_before, + d_rate_before, + ); +} diff --git a/contracts/strategies/blend_leverage/src/test_leverage.rs b/contracts/strategies/blend_leverage/src/test_leverage.rs index f226839..8d4a6a4 100644 --- a/contracts/strategies/blend_leverage/src/test_leverage.rs +++ b/contracts/strategies/blend_leverage/src/test_leverage.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - //! Unit tests for leverage math, equity calculation, share accounting, and safety checks. use crate::constants::{FIRST_DEPOSIT_LOCKUP, SCALAR_12, SCALAR_7}; @@ -828,11 +826,32 @@ fn test_safety_allows_healthy_pool() { #[test] fn test_admin_storage_roundtrip() { + use admin_sep::Administratable; let e = Env::default(); + e.mock_all_auths(); with_contract(&e, |e, _| { let admin = Address::generate(e); - storage::set_admin(e, &admin); - assert_eq!(storage::get_admin(e), admin); + crate::BlendLeverageStrategy::set_admin(e, &admin); + assert_eq!(crate::BlendLeverageStrategy::admin(e), admin); + }); +} + +// Proves the admin-sep gating is real: once an admin exists, rotating it via +// `set_admin` requires the *current* admin's authorization. The existing +// integration tests run under `mock_all_auths`, so this is the test that +// actually exercises the `require_auth` path (no auth mocked → must panic). +#[test] +#[should_panic] +fn test_set_admin_requires_current_admin_auth() { + use admin_sep::Administratable; + let e = Env::default(); + with_contract(&e, |e, _| { + // First assignment: no prior admin, so no auth needed. + let admin1 = Address::generate(e); + crate::BlendLeverageStrategy::set_admin(e, &admin1); + // Rotation: admin1's auth is NOT mocked → require_auth must reject. + let admin2 = Address::generate(e); + crate::BlendLeverageStrategy::set_admin(e, &admin2); }); } diff --git a/contracts/tokens/vault_share/Cargo.lock b/contracts/tokens/vault_share/Cargo.lock index a14390e..36fab6a 100644 --- a/contracts/tokens/vault_share/Cargo.lock +++ b/contracts/tokens/vault_share/Cargo.lock @@ -2,6 +2,14 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "admin-sep" +version = "0.0.1" +source = "git+https://github.com/theahaco/admin-sep?rev=46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028#46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1665,6 +1673,7 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" name = "vault_share_token" version = "0.1.0" dependencies = [ + "admin-sep", "soroban-sdk", ] diff --git a/contracts/tokens/vault_share/Cargo.toml b/contracts/tokens/vault_share/Cargo.toml index 80193cc..64986c5 100644 --- a/contracts/tokens/vault_share/Cargo.toml +++ b/contracts/tokens/vault_share/Cargo.toml @@ -5,11 +5,15 @@ edition = "2021" publish = false [lib] -crate-type = ["cdylib"] +# `rlib` in addition to `cdylib` so other contract crates (e.g. the strategy's +# integration tests) can register this token natively; the wasm artifact is +# still produced from the `cdylib` output. +crate-type = ["cdylib", "rlib"] doctest = false [dependencies] soroban-sdk = "25.3.0" +admin-sep = { git = "https://github.com/theahaco/admin-sep", rev = "46ed159ff38ee81f4b61b5ddb8ca4b6bdf972028" } [dev-dependencies] soroban-sdk = { version = "25.3.0", features = ["testutils"] } diff --git a/contracts/tokens/vault_share/src/lib.rs b/contracts/tokens/vault_share/src/lib.rs index e0b7d5a..29ce87d 100644 --- a/contracts/tokens/vault_share/src/lib.rs +++ b/contracts/tokens/vault_share/src/lib.rs @@ -20,6 +20,7 @@ mod storage; #[cfg(test)] mod test; +use admin_sep::{Administratable, AdministratableExtension}; use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, Address, Env, String}; use storage::TokenMetadata; @@ -49,7 +50,8 @@ pub struct VaultShareToken; impl VaultShareToken { /// Initialize the token. /// - /// - `admin` — may set the minter and upgrade the contract. + /// - `admin` — may rotate the minter and rotate the admin (no upgrade: + /// the share-ledger code is immutable). /// - `minter` — the only account allowed to mint/burn (the strategy). /// - `decimals`/`name`/`symbol` — token metadata. pub fn __constructor( @@ -60,7 +62,7 @@ impl VaultShareToken { name: String, symbol: String, ) { - storage::set_admin(&e, &admin); + Self::set_admin(&e, &admin); storage::set_minter(&e, &minter); storage::set_metadata( &e, @@ -184,7 +186,12 @@ impl VaultShareToken { from.require_auth(); check_nonnegative(amount)?; Self::spend(&e, &from, amount)?; - storage::set_total_supply(&e, storage::get_total_supply(&e) - amount); + storage::set_total_supply( + &e, + storage::get_total_supply(&e) + .checked_sub(amount) + .ok_or(TokenError::InsufficientBalance)?, + ); storage::extend_instance(&e); e.events().publish((symbol_short!("burn"), from), amount); Ok(()) @@ -200,7 +207,12 @@ impl VaultShareToken { check_nonnegative(amount)?; Self::spend_allowance(&e, &from, &spender, amount)?; Self::spend(&e, &from, amount)?; - storage::set_total_supply(&e, storage::get_total_supply(&e) - amount); + storage::set_total_supply( + &e, + storage::get_total_supply(&e) + .checked_sub(amount) + .ok_or(TokenError::InsufficientBalance)?, + ); storage::extend_instance(&e); e.events().publish((symbol_short!("burn"), from), amount); Ok(()) @@ -226,7 +238,12 @@ impl VaultShareToken { minter.require_auth(); check_nonnegative(amount)?; Self::receive(&e, &to, amount)?; - storage::set_total_supply(&e, storage::get_total_supply(&e) + amount); + storage::set_total_supply( + &e, + storage::get_total_supply(&e) + .checked_add(amount) + .ok_or(TokenError::NegativeAmount)?, + ); storage::extend_instance(&e); e.events() .publish((symbol_short!("mint"), minter, to), amount); @@ -240,7 +257,12 @@ impl VaultShareToken { minter.require_auth(); check_nonnegative(amount)?; Self::spend(&e, &from, amount)?; - storage::set_total_supply(&e, storage::get_total_supply(&e) - amount); + storage::set_total_supply( + &e, + storage::get_total_supply(&e) + .checked_sub(amount) + .ok_or(TokenError::InsufficientBalance)?, + ); storage::extend_instance(&e); e.events().publish((symbol_short!("burn"), from), amount); Ok(()) @@ -254,15 +276,25 @@ impl VaultShareToken { storage::get_minter(&e) } - pub fn admin(e: Env) -> Address { - storage::get_admin(&e) - } - /// Rotate the minter (admin only) — e.g. when the strategy is redeployed. pub fn set_minter(e: Env, new_minter: Address) -> Result<(), TokenError> { - storage::get_admin(&e).require_auth(); + Self::require_admin(&e); storage::set_minter(&e, &new_minter); storage::extend_instance(&e); Ok(()) } } + +// ── Administration (admin-sep SEP) ─────────────────────────────────────────── +// +// Admin storage and the `admin` / `set_admin` entrypoints come from the +// `admin-sep` Administratable trait (admin lives in instance storage under the +// SEP's canonical key). `set_admin` is current-admin-gated, giving the token a +// real admin-rotation path. The constructor seeds the admin via `Self::set_admin`. +// +// Deliberately NOT `Upgradable`: the share ledger is immutable code, so holders +// can trust their balances can never be rewritten by an admin upgrade. The only +// admin powers are rotating the minter and rotating the admin itself. + +#[contractimpl(contracttrait)] +impl Administratable for VaultShareToken {} diff --git a/contracts/tokens/vault_share/src/storage.rs b/contracts/tokens/vault_share/src/storage.rs index 164a60e..e26063e 100644 --- a/contracts/tokens/vault_share/src/storage.rs +++ b/contracts/tokens/vault_share/src/storage.rs @@ -43,8 +43,6 @@ pub enum DataKey { Balance(Address), /// Per (owner, spender) allowance. Allowance(AllowanceKey), - /// Account allowed to upgrade / set the minter. - Admin, /// Account allowed to mint and burn (the strategy contract). Minter, /// Total tokens outstanding. @@ -62,17 +60,9 @@ pub fn extend_instance(e: &Env) { } // ── Admin / minter / metadata ──────────────────────────────────────────────── - -pub fn set_admin(e: &Env, admin: &Address) { - e.storage().instance().set(&DataKey::Admin, admin); -} - -pub fn get_admin(e: &Env) -> Address { - e.storage() - .instance() - .get(&DataKey::Admin) - .expect("admin not set") -} +// +// Admin storage now lives in the `admin-sep` Administratable trait (see lib.rs), +// under the SEP's canonical instance-storage key — there is no local Admin key. pub fn set_minter(e: &Env, minter: &Address) { e.storage().instance().set(&DataKey::Minter, minter); diff --git a/contracts/tokens/vault_share/src/test.rs b/contracts/tokens/vault_share/src/test.rs index e48ac8c..2b60301 100644 --- a/contracts/tokens/vault_share/src/test.rs +++ b/contracts/tokens/vault_share/src/test.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - use soroban_sdk::{ testutils::{Address as _, Ledger as _}, Address, Env, String, @@ -220,6 +218,26 @@ fn test_set_minter() { assert_eq!(f.client.minter(), new_minter); } +// admin-sep rotation: the current admin can hand the admin role over. +#[test] +fn test_set_admin_rotates_under_admin_auth() { + let f = setup(); + f.e.mock_all_auths(); + let new_admin = Address::generate(&f.e); + f.client.set_admin(&new_admin); + assert_eq!(f.client.admin(), new_admin); +} + +// Proves the gating is real (not just mocked): rotating the admin requires the +// current admin's authorization — no auth mocked → must reject. +#[test] +#[should_panic] +fn test_set_admin_requires_current_admin_auth() { + let f = setup(); + let new_admin = Address::generate(&f.e); + f.client.set_admin(&new_admin); +} + #[test] fn test_total_supply_conserved_across_transfers() { let f = setup(); diff --git a/docs/d1-deploy-readiness.md b/docs/d1-deploy-readiness.md new file mode 100644 index 0000000..36736c4 --- /dev/null +++ b/docs/d1-deploy-readiness.md @@ -0,0 +1,75 @@ +# D1 — Mainnet Vault Deploy: Readiness + +> **State: one signed command away.** Everything in our control is done + +> verified; the deploy itself is gated on the operator's keys + a risk-param +> sign-off (real funds). _Prepared 2026-06-15._ + +## Ready ✅ + +- **Deploy script:** `scripts/deploy_strategy_mainnet.ts` — installs both WASMs, + then per asset deploys the 10-arg `blend_leverage_strategy` + a `vault_share` + SEP-41 token, wires `set_share_token` + `set_swap_account(keeper)`, and writes + `deployed-vaults.mainnet.json`. +- **WASMs built** at the exact paths the script reads: + - `contracts/strategies/blend_leverage/target/wasm32v1-none/release/blend_leverage_strategy.wasm` + - `contracts/tokens/vault_share/target/wasm32v1-none/release/vault_share_token.wasm` + - (rebuild: `cd && cargo build --target wasm32v1-none --release`) +- **Config verified against LIVE mainnet** (Etherfuse pool `CDMAVJPF…`, status=1 + active; Soroswap router `CAG5LRYQ…`). Per-asset strategy `c_factor` sits safely + below the pool's current value (buffer for HF): + + | Asset | Contract | Strategy cFactor | Pool cFactor (live) | Loops | min_hf | orange_hf | + |---|---|---|---|---|---|---| + | USDC | `CCW67TSZ…JMI75` | 0.90 | 0.95 | 4 | 1.05 | 1.15 | + | USTRY | `CBLV4ATS…PNUR` | 0.85 | 0.90 | 3 | 1.05 | 1.15 | + | CETES | `CAL6ER2T…6VXV` | 0.75 | 0.80 | 3 | 1.05 | 1.15 | + | XLM | `CAS3J7GY…OWMA` | 0.70 | 0.75 | 2 | 1.10 | 1.20 | + + `reward_threshold = 100 BLND`. (Pool's 5th reserve TESOURO is intentionally out + of the D1 4-asset scope.) + +## Operator inputs (the gate) ⛔ + +1. **`DEPLOY_SECRET_KEY`** — `S…` deployer secret, via 1Password / a secrets + manager (never inline a mainnet key). Account must be **funded** (~30–50 XLM + buffer: 2 WASM installs + 4×[deploy strategy + deploy token + 2 wirings]). +2. **`KEEPER_PUBKEY`** — `G…` keeper account (runs harvest + `rebalance_keeper`, + pulls BLND). **Required.** +3. **`ADMIN_PUBKEY`** — `G…` admin (upgrade + `set_share_token`/`set_swap_account`). + Defaults to the deployer; **recommend a multisig/hardware key**. +4. **Risk-param sign-off** — approve (or adjust) the per-asset `c_factor` / + `target_loops` / `min_hf` / `orange_hf` / `reward_threshold` table above. + +## Run it + +```bash +# Pre-flight (validates deployer is funded + the WASM install simulates): +op run -- env DRY_RUN=1 \ + DEPLOY_SECRET_KEY=op://Private/turbolong-deployer/secret \ + KEEPER_PUBKEY=G... ADMIN_PUBKEY=G... \ + npx tsx scripts/deploy_strategy_mainnet.ts + +# Real deploy (drop DRY_RUN): +op run -- env \ + DEPLOY_SECRET_KEY=op://Private/turbolong-deployer/secret \ + KEEPER_PUBKEY=G... ADMIN_PUBKEY=G... \ + npx tsx scripts/deploy_strategy_mainnet.ts +``` + +> Note: `DRY_RUN=1` validates the deployer account + the first WASM install +> simulation. The full chain can't be fully dry-run (each deploy depends on the +> prior contract existing on-chain) — the proven rehearsal is the **testnet** +> deploy (`scripts/deploy_strategy.ts`, the live testnet vault `CDOET…`). + +## After deploy + +1. Wire the 4 vaultIds into `frontend/src/defindex.ts` `MAINNET_VAULTS` + (`scripts/wire_mainnet_vaults.ts` reads `deployed-vaults.mainnet.json`). +2. Per asset: small **deposit → loop → withdraw** on mainnet; capture tx hashes + (Stellar Expert) for the T1/T3 reports. +3. **DeFindex co-sign** of the 4 deployments (external). +4. Fill the ⛔ rows in `docs/scf-t1-completion-report.md` + + `docs/scf-t3-completion-report.md` (vault IDs, receipt-token addresses, tx + hashes), then file. + +Refs: `docs/mainnet-go-live-runbook.md`, `docs/migration-runbook.md`. diff --git a/docs/launch-runbook.md b/docs/launch-runbook.md index e91cdb9..b50c460 100644 --- a/docs/launch-runbook.md +++ b/docs/launch-runbook.md @@ -13,7 +13,7 @@ rollback, comms, and the post-launch watch. ## 0. Pre-launch checklist (gate — all must be true) -- [ ] **Release gate green** — see `docs/testing-programme.md` (71 contract tests, +- [ ] **Release gate green** — see `docs/testing-programme.md` (80 contract tests, parity, lint/build, e2e, audit, gitleaks). - [ ] Final mainnet WASM built (`cargo build --target wasm32v1-none --release`), hash recorded — includes D2 receipt token + D3 admin/upgrade. diff --git a/docs/migration-runbook.md b/docs/migration-runbook.md index 2c9cb7b..15b577d 100644 --- a/docs/migration-runbook.md +++ b/docs/migration-runbook.md @@ -60,10 +60,14 @@ admin is set at construction (`init_args[9]`). A `version()` counter starts at - each sampled `balance(depositor)` identical within **1e-7** (expected: exactly equal — storage is untouched) - The same invariant is asserted in unit tests - (`test_upgrade_preserves_hf_and_balance_parity` in `src/test_leverage.rs`): - the same stored reserves yield byte-identical equity, HF, and per-share - underlying. The on-chain step above is the live-pool-state confirmation. + The same invariant is asserted in tests at two levels: a seeded-reserves unit + test (`test_upgrade_preserves_hf_and_balance_parity` in `src/test_leverage.rs`) + and a **live `BlendFixture` pool-state** integration test + (`test_upgrade_preserves_hf_and_balance_on_live_pool_state` in + `src/test_integration.rs`) that builds a real leveraged position with drifted + rates, calls the real admin-gated `upgrade()` entrypoint (version bump), and + asserts equity, HF, and per-user balance are identical **within 1e-7** pre/post. + The on-chain step above is the mainnet confirmation of the same invariant. ## Testnet rehearsal (do this before any mainnet upgrade) diff --git a/docs/scf-t1-completion-report.md b/docs/scf-t1-completion-report.md index 5159aa8..28bd89c 100644 --- a/docs/scf-t1-completion-report.md +++ b/docs/scf-t1-completion-report.md @@ -19,15 +19,16 @@ |---|-----------------|--------|----------| | D1 | 4-asset mainnet vault deployment ($7k) | ⛔ Prepped, not deployed | Deploy script `scripts/deploy_strategy_mainnet.ts` (4 strategy + 4 `vault_share`, `set_share_token` + `set_swap_account`, writes `deployed-vaults.mainnet.json`) — PR #271. Frontend wiring + `MAINNET_VAULTS` — PR #272. `docs/mainnet-go-live-runbook.md`. Config sourced (Soroswap router, live pool c_factors). ⛔ Deploy + per-asset deposit→loop→withdraw verification + DeFindex co-sign pending. | | D2 | Full SEP-41 receipt token ($4k) | ✅ Done | Separate `vault_share` SEP-41 contract (resolves the trait-`balance` vs SEP-41-`balance` collision). `transfer`/`approve`/`allowance`/`transfer_from`, `total_supply == Σ balances`. **15 unit tests** (`contracts/tokens/vault_share/src/test.rs`). Mainnet address captured at D1 deploy. | -| D3 | In-place WASM upgrade + admin ($2k) | ✅ Done | `upgrade()` + admin role + `version()`; storage/positions preserved. Upgrade-parity tests assert Config, every `VaultPos`, `total_shares`, recomputed HF identical **within 1e-7** pre/post (`test_integration.rs`, **13 tests**). Runbook: `docs/migration-runbook.md`. | +| D3 | In-place WASM upgrade + admin ($2k) | ✅ Done | `upgrade()` + admin role + `version()`; storage/positions preserved. Upgrade-parity asserted at two levels: a seeded-reserves unit test (`test_upgrade_preserves_hf_and_balance_parity`, `test_leverage.rs`) and a **live `BlendFixture` pool-state** integration test (`test_upgrade_preserves_hf_and_balance_on_live_pool_state`, `test_integration.rs`) that drives a real deposit + drifted rates through the real `upgrade()` entrypoint — equity, HF, and per-user balance identical **within 1e-7** pre/post. Runbook: `docs/migration-runbook.md`. | | D4 | Wallets Kit mobile + Ledger + E2E ($5k) | ✅ Code done / ⛔ device sign-off | Ledger module + WalletConnect mobile deep-link + Playwright e2e (5 wallets × {classic, Soroban}, mock seam) — PR #259. ⛔ Physical Ledger run + iOS/Android device runs (Lobstr/xBull) + wallet-team sign-off pending (external). | | D5 | CI / supply-chain hygiene ($2k) | ✅ Done | `dependabot.yml`, `cargo-audit.yml`, Clippy `-D warnings` + `cargo fmt --check` (`contracts.yml`), Biome + build + e2e (`frontend-ci.yml`), `secret-scan.yml` (gitleaks) + `.gitleaks.toml` + pre-commit. Dependabot actively opening PRs. ✅ gitleaks blocks a planted Stellar key (rule `stellar-secret-key`, exit 1) — local proof captured in `docs/evidence/gitleaks-block-demo.md`. | ## Verifiable now (on `main`) -- **Contracts:** 71 tests green (43 `test_leverage.rs`, 13 `test_integration.rs` - incl. upgrade-parity 1e-7, 15 `vault_share/test.rs`); clippy `-D warnings` + - `cargo fmt --check` enforced; wasm builds. +- **Contracts:** 80 tests green (43 `test_leverage.rs`, 22 `test_integration.rs`, + 15 `vault_share/test.rs`); upgrade-parity within 1e-7 on a seeded fixture + (`test_leverage.rs`) and a live Blend pool-state fixture (`test_integration.rs`); + clippy `-D warnings` + `cargo fmt --check` enforced; wasm builds. - **Rate parity:** TS `projectRates` ↔ Rust `rate_calc` over ≥20 IR-kink fixtures (`parity.yml`). - **Frontend/Worker:** Biome + Vite build + Playwright e2e (`frontend-ci.yml`). diff --git a/docs/scf-t2-completion-report.md b/docs/scf-t2-completion-report.md index 0d6d6bf..8c0a2d4 100644 --- a/docs/scf-t2-completion-report.md +++ b/docs/scf-t2-completion-report.md @@ -28,7 +28,7 @@ _Per-deliverable amounts per the SCF #43 application §8 (not restated here)._ - **Contracts:** `partial_unwind` / `compute_partial_unwind`, `rebalance` / `rebalance_keeper`, split `harvest_claim` / `harvest_reinvest` — covered by the - contract test suite (71 tests green) + clippy `-D warnings` + `cargo fmt`. + contract test suite (80 tests green) + clippy `-D warnings` + `cargo fmt`. - **Rate model:** TS `projectRates` ↔ Rust `rate_calc` parity within 1e-7 over **34 IR-kink fixtures** (`parity.yml`). - **Swap routing:** dual-quote best-route logic + slippage floor + A/B telemetry diff --git a/docs/scf-t3-completion-report.md b/docs/scf-t3-completion-report.md index 5d5412c..d6eb397 100644 --- a/docs/scf-t3-completion-report.md +++ b/docs/scf-t3-completion-report.md @@ -18,7 +18,7 @@ | T3.1 | Aquarius rate comparison + Best Rate UI | ✅ Built | PR #273 — `aquarius.ts` (find-path client), `history.ts` (server-backed trends), swap-view Aquarius rate + Best Rate badge | | T3.2 | Aquarius SEP-41 receipt-token listing + trade UX | ✅ Built / ⛔ listing | PR #275 — vault "Trade on Aquarius" CTA + `aquarius_listings.ts` registry + `docs/aquarius-listing-runbook.md`. ⛔ Pool creation, liquidity, **5 verification trades** at launch | | T3.3 | Compare Pools view (cross-pool APY + Aquarius + history) | ✅ Built | PR #274 — no-wallet Compare view, ranked leveraged net APY, Aquarius rates, 7D/30D/1Y history sparklines, Best Rate badge (issue #11) | -| T3.4 | Structured testing programme | ✅ Built | PR #276 — `docs/testing-programme.md` (6 layers, 71 contract tests, parity, e2e, release gate) | +| T3.4 | Structured testing programme | ✅ Built | PR #276 — `docs/testing-programme.md` (6 layers, 80 contract tests, parity, e2e, release gate) | | T3.5 | Mainnet launch (i18n, status page, onboarding, runbook, report) | ✅ Built / ⛔ launch | This PR — i18n EN/ES/PT-BR + language switcher, onboarding tour, `/status.html`, `docs/launch-runbook.md`, this report. ⛔ Go-live + 14-day-green watch | ## What shipped (verifiable now) diff --git a/docs/testing-programme.md b/docs/testing-programme.md index b9c5e11..8122c9b 100644 --- a/docs/testing-programme.md +++ b/docs/testing-programme.md @@ -19,7 +19,7 @@ relies on to quote positions. ┌─ E2E wallet flows (Playwright, mock seam) ─┐ Layer 4 ┌─ Frontend lint + build (Biome, Vite) ─┐ Layer 3 ┌─ Rate-model parity (TS ↔ Rust, ≥20 fixtures) ─┐ Layer 2 - ┌─ Contract unit + integration (Rust, 71 tests) ───────┐ Layer 1 + ┌─ Contract unit + integration (Rust, 80 tests) ───────┐ Layer 1 └────────── Supply-chain / secret scanning (always-on) ─┘ Layer 0 ``` @@ -37,12 +37,12 @@ relies on to quote positions. ## Layer 1 — Contract unit & integration (Rust) — *primary* -The fund-holding logic. **71 tests** across: +The fund-holding logic. **80 tests** across: | Crate / file | Tests | Covers | |--------------|-------|--------| -| `contracts/strategies/blend_leverage/src/test_leverage.rs` | 43 | leverage-loop open/close, health-factor math, partial unwind, `orange_hf` band, rebalance keeper, BLND harvest split (`harvest_claim` / `harvest_reinvest`), interest-rate kink, utilization-cap panics (e.g. `#[should_panic("Error(Contract, #422)")]` at >95% util) | -| `contracts/strategies/blend_leverage/src/test_integration.rs` | 13 | in-place WASM `upgrade()` parity — Config, every `VaultPos`, `total_shares`, recomputed HF identical **within 1e-7** pre/post upgrade; admin auth; degenerate cases (zero positions, single user, post-harvest rates) | +| `contracts/strategies/blend_leverage/src/test_leverage.rs` | 43 | leverage-loop open/close, health-factor math, partial unwind, `orange_hf` band, rebalance keeper, BLND harvest split (`harvest_claim` / `harvest_reinvest`), interest-rate kink, utilization-cap panics (e.g. `#[should_panic("Error(Contract, #422)")]` at >95% util); **version counter** (`test_version_defaults_to_one_then_bumps`) and **upgrade-parity on a seeded fixture** (`test_upgrade_preserves_hf_and_balance_parity`) — equity/HF/per-share underlying identical **within 1e-7** | +| `contracts/strategies/blend_leverage/src/test_integration.rs` | 22 | integration against a real Blend pool (`BlendFixture`): supply/borrow, leverage-loop build, full deposit→withdraw cycle, two-user proportionality, deleverage/partial-unwind, rebalance round-trip, harvest, real `deposit`/`withdraw` entrypoints keep reserves in sync, share-token wiring + `migrate_position`, transferred-share withdraw; **live pool-state `upgrade()` parity** (`test_upgrade_preserves_hf_and_balance_on_live_pool_state`) — real deposit + drifted rates, the real `upgrade()` entrypoint (admin-gated, version bump), equity/HF/per-user balance identical **within 1e-7** pre/post | | `contracts/tokens/vault_share/src/test.rs` | 15 | SEP-41 receipt token: `transfer` moves shares + proportional claim (no pool interaction), insufficient-balance/non-positive guards, `approve`/`allowance`/`transfer_from` happy + expiry + over-allowance, `total_supply == Σ balances` | **Run:** `cd contracts/strategies/blend_leverage && cargo test`; @@ -118,7 +118,7 @@ Executed at/after deploy; evidence captured for the SCF completion report. ## Release gate — must be green before any mainnet change -- [ ] `cargo test` (all 71 contract tests) green. +- [ ] `cargo test` (all 80 contract tests) green. - [ ] `cargo clippy --all-targets -- -D warnings` + `cargo fmt --check` clean. - [ ] `cargo build --target wasm32v1-none --release` produces the final WASM. - [ ] Parity suite green (`rate_calc` ↔ `projectRates`, ≥20 fixtures). @@ -131,7 +131,10 @@ Executed at/after deploy; evidence captured for the SCF completion report. ## Test data & fixtures - Rate-model fixtures: `tests/fixtures/rates.json` (IR-kink scenarios for parity). -- Live pool-state fixtures for upgrade-parity: in `test_integration.rs`. +- Upgrade-parity fixtures: a seeded reserves fixture in `test_leverage.rs` + (`test_upgrade_preserves_hf_and_balance_parity`) and a live `BlendFixture` + pool-state fixture in `test_integration.rs` + (`test_upgrade_preserves_hf_and_balance_on_live_pool_state`). ## Cadence & ownership diff --git a/frontend/src/app/state.ts b/frontend/src/app/state.ts index 4857885..7b1b983 100644 --- a/frontend/src/app/state.ts +++ b/frontend/src/app/state.ts @@ -9,6 +9,17 @@ export type View = "dashboard" | "trade" | "vault" | "compare" | "swap" | "statu export type Theme = "light" | "dark"; export type Lang = "en" | "es" | "pt"; +/** + * One-shot deep-link target for the Trade screen. Set by the dashboard's + * Manage / Add leg buttons just before navigating to "trade"; the trade screen + * consumes it on mount (selecting the pool + asset) and clears it so a later + * manual visit starts on the default pool. + */ +export interface TradeTarget { + poolId: string; + assetId?: string; +} + export interface AppState { view: View; userAddress: string | null; @@ -17,6 +28,7 @@ export interface AppState { theme: Theme; expert: boolean; lang: Lang; + tradeTarget: TradeTarget | null; } const state: AppState = { @@ -27,6 +39,7 @@ const state: AppState = { theme: "dark", expert: false, lang: "en", + tradeTarget: null, }; type Listener = (s: AppState) => void; diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index f9b15e7..5ddc025 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -761,6 +761,7 @@ export type PoolAccountRole = "loop" | "borrow" | "collateral"; export interface PoolAccountRow { symbol: string; + assetId: string; // asset contract ID — lets the dashboard deep-link to this leg supplied: number; // full tokens supplied (collateral) in this asset, 0 if none borrowed: number; // full tokens borrowed (debt) in this asset, 0 if none role: PoolAccountRole; @@ -829,6 +830,7 @@ export function aggregatePoolAccount( rows.push({ symbol: pos.asset.symbol, + assetId: pos.asset.id, supplied: pos.collateral, borrowed: pos.debt, role, diff --git a/frontend/src/ui/healthFactor.ts b/frontend/src/ui/healthFactor.ts index 433688e..bd416fb 100644 --- a/frontend/src/ui/healthFactor.ts +++ b/frontend/src/ui/healthFactor.ts @@ -71,7 +71,7 @@ export function HealthFactor(props: HealthFactorProps): HTMLDivElement { ) : null, el("span", { class: "tl-hf__readout" }, [ - el("span", { class: "tl-hf__value", style: `color:${z.tone}` }, [value.toFixed(2)]), + el("span", { class: "tl-hf__value", style: `color:${z.tone}` }, [Number.isFinite(value) ? value.toFixed(4) : "∞"]), el("span", { class: "tl-hf__zone", style: `color:${z.tone}` }, [z.label]), ]), ]); diff --git a/frontend/src/views/dashboard.screen.ts b/frontend/src/views/dashboard.screen.ts index e75e36e..f21579a 100644 --- a/frontend/src/views/dashboard.screen.ts +++ b/frontend/src/views/dashboard.screen.ts @@ -29,7 +29,7 @@ function legsFromRows( const price = priceBySymbol.get(r.symbol) ?? 0; const role: Role = r.role === "loop" ? "Looped" : r.role === "collateral" ? "Collateral" : "Borrow"; const amountUsd = (r.role === "borrow" ? r.borrowed : r.supplied) * price; - const leg: Leg = { asset: r.symbol, role, amountUsd }; + const leg: Leg = { asset: r.symbol, assetId: r.assetId, role, amountUsd }; if (r.role === "loop") leg.loopX = r.leverage; return leg; }); @@ -52,6 +52,7 @@ export async function loadDashboardData(addr: string): Promise { const priceBySymbol = new Map(reserves.map((rs) => [rs.asset.symbol, rs.priceUsd])); poolAccounts.push({ pool: pool.name, + poolId: pool.id, legs: legsFromRows(agg.rows, priceBySymbol), equityUsd: agg.equityUsd, netApy: agg.netApy, @@ -94,8 +95,11 @@ export async function loadDashboardData(addr: string): Promise { } const handlers = { - onNewPosition: () => setState({ view: "trade" }), - onManagePool: () => setState({ view: "trade" }), + onNewPosition: () => setState({ view: "trade", tradeTarget: null }), + onManagePool: (poolId: string | undefined, assetId: string | undefined) => + setState({ view: "trade", tradeTarget: poolId ? { poolId, assetId } : null }), + onAddLeg: (poolId: string | undefined) => + setState({ view: "trade", tradeTarget: poolId ? { poolId } : null }), onGoVault: () => setState({ view: "vault" }), }; diff --git a/frontend/src/views/dashboard.ts b/frontend/src/views/dashboard.ts index b4c31b3..70427bf 100644 --- a/frontend/src/views/dashboard.ts +++ b/frontend/src/views/dashboard.ts @@ -19,6 +19,7 @@ export type Role = 'Looped' | 'Collateral' | 'Borrow'; export interface Leg { asset: string; // e.g. "USDC" + assetId?: string; // asset contract ID — lets Manage deep-link to this exact leg role: Role; amountUsd: number; // USD value of this leg loopX?: number; // present for Looped legs, e.g. 5.0 @@ -26,7 +27,8 @@ export interface Leg { } export interface PoolAccount { - pool: string; // e.g. "YieldBlox" + pool: string; // e.g. "YieldBlox" (display name) + poolId?: string; // pool contract ID — lets Manage / Add leg deep-link to this pool legs: Leg[]; equityUsd: number; // collateral − debt, summed for this pool netApy: number; // %, can be negative @@ -114,7 +116,7 @@ function accountHealth(hf: number, title: string): HTMLElement { return el('div', { class: 'tl-hf' }, [ el('div', { class: 'tl-hf__top' }, [ el('span', { class: 'tl-hf__label', title }, ['Account Health']), - el('span', { class: 'tl-hf__val', style: `color:${color}` }, [hf.toFixed(2) + ' · ' + hfLabel(hf)]), + el('span', { class: 'tl-hf__val', style: `color:${color}` }, [(Number.isFinite(hf) ? hf.toFixed(4) : '∞') + ' · ' + hfLabel(hf)]), ]), el('div', { class: 'tl-hf__track' }, [ el('div', { class: 'tl-hf__grad' }, []), @@ -199,7 +201,7 @@ function vaultCard(v: VaultHolding, onManage: () => void): HTMLElement { el('div', { class: 'tl-strategy' }, [ el('span', { class: 'tl-dot' }, []), 'Strategy Health ', - el('span', { class: 'tl-mono tl-strategy__hf' }, [v.strategyHealth.toFixed(2)]), + el('span', { class: 'tl-mono tl-strategy__hf' }, [Number.isFinite(v.strategyHealth) ? v.strategyHealth.toFixed(4) : '∞']), ' · keeper-protected', ]), manage, @@ -208,8 +210,12 @@ function vaultCard(v: VaultHolding, onManage: () => void): HTMLElement { // ── Screen ────────────────────────────────────────────────────────────────── export interface DashboardHandlers { + /** "+ New Position" header button — open Trade with no preset. */ onNewPosition: () => void; - onManagePool: (pool: string) => void; + /** "Manage" — open Trade focused on this pool + the account's primary leg. */ + onManagePool: (poolId: string | undefined, assetId: string | undefined) => void; + /** "Add leg" — open Trade focused on this pool so the user can add another asset. */ + onAddLeg: (poolId: string | undefined) => void; onGoVault: () => void; } @@ -244,14 +250,25 @@ export function renderDashboard(data: DashboardData, h: DashboardHandlers): HTML metricHero('Total Equity', money(totalEquity), undefined, 'Your own capital across all pools and vaults — collateral value minus debt, summed.'), metricHero('Avg Net APY', pct(wAvgApy), wAvgApy >= 0 ? 'var(--tl-success)' : 'var(--tl-danger)'), metricHero('Positions', String(data.poolAccounts.length + data.vaults.length), undefined, 'Your active pool accounts plus passive vault positions.'), - metricHero('Lowest Account Health', lowestHf ? lowestHf.toFixed(2) : '—', hfColor(lowestHf || 99), 'The riskiest pool account. Below 1.0 the whole pool account is liquidated.'), + metricHero('Lowest Account Health', lowestHf ? (Number.isFinite(lowestHf) ? lowestHf.toFixed(4) : '∞') : '—', hfColor(lowestHf || 99), 'The riskiest pool account. Below 1.0 the whole pool account is liquidated.'), ])); // Group A — Pool Accounts if (data.poolAccounts.length) { root.append(el('h2', { class: 'tl-group' }, ['Pool Accounts ', el('span', { class: 'tl-group__sub' }, ['active · self-managed'])])); root.append(el('div', { class: 'tl-grid tl-grid--2' }, - data.poolAccounts.map((acc) => poolCard(acc, () => h.onManagePool(acc.pool), h.onNewPosition)))); + data.poolAccounts.map((acc) => { + // Manage targets the account's primary leg: the looped one if present, + // else the largest leg by USD — so a single-asset account lands exactly + // on that asset rather than the pool's default tab. + const primary = acc.legs.find((l) => l.role === 'Looped') + ?? [...acc.legs].sort((a, b) => b.amountUsd - a.amountUsd)[0]; + return poolCard( + acc, + () => h.onManagePool(acc.poolId, primary?.assetId), + () => h.onAddLeg(acc.poolId), + ); + }))); } // Group B — Vaults @@ -290,7 +307,8 @@ export const SEED_DASHBOARD: DashboardData = { import { renderDashboard, SEED_DASHBOARD } from './dashboard'; const view = renderDashboard(SEED_DASHBOARD, { onNewPosition: () => router.go('trade'), - onManagePool: (pool) => router.go('trade', { pool }), + onManagePool: (poolId, assetId) => router.go('trade', { poolId, assetId }), + onAddLeg: (poolId) => router.go('trade', { poolId }), onGoVault: () => router.go('vault'), }); document.getElementById('app')!.replaceChildren(view); diff --git a/frontend/src/views/trade.ts b/frontend/src/views/trade.ts index 9874c3c..5b51f75 100644 --- a/frontend/src/views/trade.ts +++ b/frontend/src/views/trade.ts @@ -62,7 +62,7 @@ import { type PositionEvent, } from "../blend"; import { fetchSnapshotSeries } from "../history"; -import { getState, subscribe } from "../app/state"; +import { getState, setState, subscribe } from "../app/state"; import { signAndSubmit, signAndSubmitClassic } from "../app/wallet"; import { toast, txShow, txStep, txHide } from "../app/chrome"; @@ -132,13 +132,19 @@ export function tradeScreen(): HTMLElement { const root = el("div", { class: "trade" }); const pools = getKnownPools(); - const pool = pools[0]; + // Deep-link target from the dashboard's Manage / Add leg buttons (one-shot). + // Resolve the requested pool + asset, falling back to defaults if unknown, + // then clear it so a later manual visit starts on the default pool. + const target = getState().tradeTarget; + const pool = (target?.poolId && pools.find((p) => p.id === target.poolId)) || pools[0]; const assets = getPoolAssets(pool); + const startAsset = (target?.assetId && assets.find((a) => a.id === target.assetId)) || assets[0]; + if (target) setState({ tradeTarget: null }); const ts: TradeState = { pool, assets, - asset: assets[0], + asset: startAsset, reserves: [], positions: { byAsset: new Map() }, balance: 0, @@ -1519,7 +1525,7 @@ export function tradeScreen(): HTMLElement { row("Total borrowed", `${fmt(borrow)} ${liveAsset.symbol}`), row( "Health Factor", - Number.isFinite(hf) ? fmt(hf, getState().expert ? 5 : 4) : "∞", + Number.isFinite(hf) ? fmt(hf, 4) : "∞", hf > 1.1 ? "trade-tone-up" : hf > 1.03 ? "trade-tone-warn" : "trade-tone-down", ), row( diff --git a/landing/.well-known/security.txt b/landing/.well-known/security.txt index c9d333c..87435bf 100644 --- a/landing/.well-known/security.txt +++ b/landing/.well-known/security.txt @@ -2,8 +2,6 @@ Contact: mailto:security@turbolong.app Contact: https://t.me/turbolong_security Expires: 2027-05-29T00:00:00.000Z Encryption: https://turbolong.app/.well-known/pgp-key.txt -Acknowledgments: https://turbolong.app/bug-bounty#acknowledgements -Policy: https://turbolong.app/bug-bounty Policy: https://github.com/kweb-drips/TurboLong/blob/main/SECURITY.md Preferred-Languages: en Canonical: https://turbolong.app/.well-known/security.txt diff --git a/landing/index.html b/landing/index.html index 2c7b056..72a3473 100644 --- a/landing/index.html +++ b/landing/index.html @@ -441,7 +441,6 @@

Start leveraging on Stellar

Blend Docs GitHub Stellar Expert - Bug Bounty diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..efdeb1f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "TurboLong", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}