diff --git a/contracts/strategies/blend_leverage/Cargo.toml b/contracts/strategies/blend_leverage/Cargo.toml index 85108d8..97c36e3 100644 --- a/contracts/strategies/blend_leverage/Cargo.toml +++ b/contracts/strategies/blend_leverage/Cargo.toml @@ -16,6 +16,7 @@ blend-contract-sdk = "2.25.0" [dev-dependencies] soroban-sdk = { version = "25.3.0", features = ["testutils"] } blend-contract-sdk = { version = "2.25.0", features = ["testutils"] } +proptest = "1" [profile.release] opt-level = "z" diff --git a/contracts/strategies/blend_leverage/README.md b/contracts/strategies/blend_leverage/README.md new file mode 100644 index 0000000..879a661 --- /dev/null +++ b/contracts/strategies/blend_leverage/README.md @@ -0,0 +1,77 @@ +# BlendLeverageStrategy + +A Soroban smart contract that implements a single-asset leveraged yield strategy on the Blend Protocol (Stellar). + +## How it works + +The strategy accepts a deposit of an underlying asset (e.g., USDC) and amplifies yield by repeatedly supplying and borrowing the same asset through the Blend pool: + +``` +Deposit $1,000 (c = 0.95, 8 loops) + Loop 0: supply $1,000 → borrow $950 + Loop 1: supply $950 → borrow $902.5 + … + Loop 8: supply ~$663 → borrow 0 (final supply, no borrow) + + Total supplied ≈ $8,025 Total borrowed ≈ $7,025 Equity = $1,000 +``` + +Yield is earned on the leveraged supply position minus the cost of the leveraged borrow position. BLND emissions on both sides are harvested and re-compounded via Soroswap. + +## Loop cap rationale + +`leverage::loop_step_count` hard-caps the number of iterations at **20 loops** (`MAX_LOOPS` in `constants.rs`). This limit exists for three reasons: + +### 1. Soroban instruction budget + +Each loop step submits two Blend pool operations (supply-collateral + borrow) as host-function calls. Soroban enforces a per-transaction CPU-instruction limit. At 20 loops (40 pool calls + overhead) the transaction is near the practical ceiling; exceeding it causes the transaction to abort with a resource-exhaustion error. + +### 2. Diminishing returns + +The leverage series is geometric: each loop's supply equals `initial × c^n`. With c = 0.95: + +| Loop | Marginal supply | Cumulative leverage | +|------|----------------|---------------------| +| 1 | 0.95 × initial | 1.95× | +| 5 | 0.77 × initial | 6.23× | +| 10 | 0.60 × initial | 10.09× | +| 20 | 0.36 × initial | 15.08× | +| ∞ | 0 | 20.00× | + +Beyond loop 20, the marginal gain is less than 4% of the total position while the risk of hitting the instruction budget grows sharply. + +### 3. Safety ceiling vs. operator knob + +`target_loops` in `Config` is the operator-configurable parameter set at initialisation. It must be ≤ `MAX_LOOPS`. The constant is a **hard safety ceiling** that prevents a misconfigured or maliciously set `target_loops` from issuing unbounded on-chain requests even if the init-time validation is absent or bypassed. + +## Initialisation parameters + +| Index | Name | Type | Description | +|-------|-------------------|-----------|-----------------------------------------------| +| 0 | `pool` | `Address` | Blend pool address | +| 1 | `blend_token` | `Address` | BLND token address | +| 2 | `router` | `Address` | Soroswap router address | +| 3 | `reward_threshold`| `i128` | Minimum BLND to trigger harvest swap | +| 4 | `keeper` | `Address` | Authorised harvest caller | +| 5 | `c_factor` | `i128` | Collateral factor (1e7 scaled, e.g. 9_500_000)| +| 6 | `target_loops` | `u32` | Number of leverage loops (≤ 20) | +| 7 | `min_hf` | `i128` | Minimum health factor (1e7 scaled) | +| 8 | `admin` | `Address` | Admin address for emergency pause | + +## Emergency pause + +The admin can halt new deposits and new leverage operations without blocking withdrawals. This protects users during pool-freeze events or if a vulnerability is discovered. + +``` +BlendLeverageStrategy::pause() — blocks deposit + harvest (admin only) +BlendLeverageStrategy::unpause() — resumes normal operation (admin only) +``` + +A `PauseStateChange` event is emitted on every state transition. + +## Key invariants + +- `total_supply - total_borrow = initial_deposit` (net equity preserved through loops) +- `health_factor = (b_tokens × b_rate × c_factor) / (d_tokens × d_rate)` ≥ `min_hf` +- Leverage ≤ `1 / (1 - c_factor)` (geometric series upper bound) +- Deposits are blocked when pool utilisation ≥ 95% diff --git a/contracts/strategies/blend_leverage/src/constants.rs b/contracts/strategies/blend_leverage/src/constants.rs index 7f5d929..4ee2359 100644 --- a/contracts/strategies/blend_leverage/src/constants.rs +++ b/contracts/strategies/blend_leverage/src/constants.rs @@ -17,6 +17,17 @@ pub const MAX_RATE_SPREAD: i128 = 15_000_000; // 15% in 1e7 /// Inflation attack protection: first depositor lockup pub const FIRST_DEPOSIT_LOCKUP: i128 = 1000; +/// Maximum number of leverage loops allowed per deposit transaction. +/// +/// Each loop step issues two pool host-function calls (supply-collateral + borrow). +/// Soroban's per-transaction instruction budget and the diminishing marginal supply +/// at high loop counts (c^20 < 0.36 for c = 0.95) make 20 the practical ceiling. +/// The operator-visible `target_loops` in `Config` is the tunable knob; this constant +/// is a hard safety ceiling that prevents misconfiguration from bricking transactions. +/// +/// See `leverage::loop_step_count` and `README.md` § "Loop cap rationale" for details. +pub const MAX_LOOPS: u32 = 20; + /// Blend v2 request type constants pub const REQUEST_TYPE_SUPPLY_COLLATERAL: u32 = 2; pub const REQUEST_TYPE_WITHDRAW_COLLATERAL: u32 = 3; diff --git a/contracts/strategies/blend_leverage/src/leverage.rs b/contracts/strategies/blend_leverage/src/leverage.rs index bf4ecb6..a44aa34 100644 --- a/contracts/strategies/blend_leverage/src/leverage.rs +++ b/contracts/strategies/blend_leverage/src/leverage.rs @@ -1,4 +1,4 @@ -use crate::constants::{MAX_SAFE_UTILIZATION, SCALAR_12, SCALAR_7}; +use crate::constants::{MAX_LOOPS, MAX_SAFE_UTILIZATION, SCALAR_12, SCALAR_7}; use crate::storage::{Config, LeverageReserves}; use defindex_strategy_core::StrategyError; use soroban_fixed_point_math::FixedPoint; @@ -33,9 +33,28 @@ pub fn compute_step(balance: i128, c_factor: i128, is_final: bool) -> (i128, i12 } /// Total number of steps in a leverage loop (n_loops supply+borrow pairs + 1 final supply). +/// +/// Hard-capped at `MAX_LOOPS` (20) loops for three reasons: +/// +/// 1. **Soroban instruction budget** – each loop step issues two pool host-function calls +/// (supply-collateral + borrow). Soroban's per-transaction CPU-instruction and +/// host-function-call limits are finite; unconstrained loops would cause the +/// transaction to abort with a resource-exhaustion error beyond ~20 iterations. +/// +/// 2. **Diminishing returns** – with c = 0.95 the marginal supply added at loop 20 is +/// initial × 0.95^20 ≈ 0.36 × initial, less than 4% of the total leveraged +/// position. Every additional loop yields strictly less; 20 is the practical plateau +/// where the additional leverage gain no longer justifies the extra on-chain cost. +/// +/// 3. **Safety ceiling vs. operator knob** – the per-deployment `target_loops` in +/// `Config` is the tunable parameter (validated at init, must be ≤ `MAX_LOOPS`). +/// This constant is a hard ceiling that prevents misconfigured `target_loops` values +/// from issuing unbounded on-chain requests even if validation is bypassed. +/// +/// See also `README.md` § "Loop cap rationale". #[inline] pub fn loop_step_count(n_loops: u32) -> u32 { - (n_loops + 1).min(21) + (n_loops + 1).min(MAX_LOOPS + 1) } /// Compute supply and borrow amounts for each loop iteration. diff --git a/contracts/strategies/blend_leverage/src/lib.rs b/contracts/strategies/blend_leverage/src/lib.rs index d878a71..1d3cc5a 100644 --- a/contracts/strategies/blend_leverage/src/lib.rs +++ b/contracts/strategies/blend_leverage/src/lib.rs @@ -11,6 +11,8 @@ mod storage; mod test_leverage; #[cfg(test)] mod test_integration; +#[cfg(test)] +mod test_proptest; use constants::SCALAR_12; pub use defindex_strategy_core::{event, DeFindexStrategyTrait, StrategyError}; @@ -19,7 +21,8 @@ use leverage::{ shares_to_underlying, }; use soroban_sdk::{ - contract, contractimpl, token::TokenClient, Address, Bytes, Env, IntoVal, String, Val, Vec, + contract, contractimpl, symbol_short, token::TokenClient, Address, Bytes, Env, IntoVal, + String, Val, Vec, }; use storage::{extend_instance_ttl, Config}; @@ -49,6 +52,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { /// [5] c_factor: i128 — collateral factor (1e7) /// [6] target_loops: u32 — number of leverage loops /// [7] min_hf: i128 — minimum health factor (1e7) + /// [8] admin: Address — admin for emergency pause fn __constructor(e: Env, asset: Address, init_args: Vec) { let pool: Address = init_args .get(0) @@ -82,6 +86,10 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { .get(7) .expect("Missing: min_hf") .into_val(&e); + let admin: Address = init_args + .get(8) + .expect("Missing: admin") + .into_val(&e); // Look up the reserve index from the pool let pool_client = blend_contract_sdk::pool::Client::new(&e, &pool); @@ -111,6 +119,7 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { storage::set_config(&e, config); storage::set_keeper(&e, &keeper); + storage::set_admin(&e, &admin); } fn asset(e: Env) -> Result { @@ -127,6 +136,9 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { /// 4. Return the depositor's underlying balance fn deposit(e: Env, amount: i128, from: Address) -> Result { extend_instance_ttl(&e); + if storage::is_paused(&e) { + return Err(StrategyError::NotAuthorized); + } check_positive_amount(amount)?; from.require_auth(); @@ -205,6 +217,9 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy { /// No new shares are minted — this increases per-share equity. fn harvest(e: Env, from: Address, data: Option) -> Result<(), StrategyError> { extend_instance_ttl(&e); + if storage::is_paused(&e) { + return Err(StrategyError::NotAuthorized); + } let keeper = storage::get_keeper(&e); keeper.require_auth(); @@ -379,4 +394,44 @@ impl BlendLeverageStrategy { reserves.d_rate, )) } + + /// Pause new deposits and leverage operations. Withdrawals remain unaffected. + /// Only the admin (set at init) may call this. + /// Emits a `PauseStateChange` event with `true`. + pub fn pause(e: Env) -> Result<(), StrategyError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + storage::set_paused(&e, true); + e.events().publish( + (symbol_short!("pause"), symbol_short!("state")), + true, + ); + Ok(()) + } + + /// Resume normal operation after a pause. Only the admin may call this. + /// Emits a `PauseStateChange` event with `false`. + pub fn unpause(e: Env) -> Result<(), StrategyError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + storage::set_paused(&e, false); + e.events().publish( + (symbol_short!("pause"), symbol_short!("state")), + false, + ); + Ok(()) + } + + /// Return whether the contract is currently paused. + pub fn paused(e: Env) -> bool { + storage::is_paused(&e) + } + + /// Get the current admin address. + pub fn get_admin(e: Env) -> Result { + extend_instance_ttl(&e); + Ok(storage::get_admin(&e)) + } } diff --git a/contracts/strategies/blend_leverage/src/storage.rs b/contracts/strategies/blend_leverage/src/storage.rs index 686d722..0783f21 100644 --- a/contracts/strategies/blend_leverage/src/storage.rs +++ b/contracts/strategies/blend_leverage/src/storage.rs @@ -19,6 +19,8 @@ pub enum DataKey { Reserves, VaultPos(Address), Keeper, + Admin, + Paused, } // ── Config ─────────────────────────────────────────────────────────────────── @@ -149,6 +151,32 @@ pub fn get_keeper(e: &Env) -> Address { .expect("Keeper not set") } +// ── 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") +} + +// ── Paused state ───────────────────────────────────────────────────────────── + +pub fn set_paused(e: &Env, paused: bool) { + e.storage().instance().set(&DataKey::Paused, &paused); +} + +pub fn is_paused(e: &Env) -> bool { + e.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) +} + // ── Instance TTL ───────────────────────────────────────────────────────────── pub fn extend_instance_ttl(e: &Env) { diff --git a/contracts/strategies/blend_leverage/src/test_proptest.rs b/contracts/strategies/blend_leverage/src/test_proptest.rs new file mode 100644 index 0000000..7f21d9a --- /dev/null +++ b/contracts/strategies/blend_leverage/src/test_proptest.rs @@ -0,0 +1,161 @@ +#![cfg(test)] +extern crate std; + +//! Property-based invariant tests for leverage math using proptest. +//! +//! Each property is run with at least 1 000 randomly-generated cases. Failing +//! inputs are automatically shrunk to the smallest reproducing example by the +//! proptest framework. +//! +//! Invariants verified: +//! 1. total_supply >= total_borrow (borrow never exceeds supply) +//! 2. total_supply - total_borrow == initial (net equity preserved) +//! 3. total_supply <= initial / (1 - c) (geometric-series upper bound) +//! 4. HF is monotone in c_factor (higher c → strictly higher HF) +//! 5. compute_step supply == balance (supply leg is always the full balance) +//! 6. compute_step final borrow == 0 (no borrow on the last step) +//! 7. no overflow / panic on large inputs (graceful saturation via checked_mul) + +use crate::constants::SCALAR_7; +use crate::leverage::{compute_health_factor, compute_step, compute_totals}; +use proptest::prelude::*; + +/// Run every proptest property with at least 1 000 cases. +const CASES: u32 = 1_000; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(CASES))] + + // ── Invariant 1 ───────────────────────────────────────────────────────── + // total_supply is always >= total_borrow for any valid inputs. + // Rationale: borrowing is always a fraction (c < 1) of supply, so the + // cumulative sum of borrows can never exceed the cumulative sum of supplies. + #[test] + fn inv_total_supply_gte_total_borrow( + initial in 1i128..=1_000_000_000_000i128, + c_factor in 100_000i128..=9_900_000i128, + n_loops in 0u32..=20u32, + ) { + let (total_supply, total_borrow) = compute_totals(initial, c_factor, n_loops); + prop_assert!( + total_supply >= total_borrow, + "total_supply ({}) < total_borrow ({}) with initial={}, c={}, n={}", + total_supply, total_borrow, initial, c_factor, n_loops + ); + } + + // ── Invariant 2 ───────────────────────────────────────────────────────── + // total_supply - total_borrow == initial (net equity equals initial deposit). + // Each loop borrows exactly what the next loop supplies, so the only + // "free" supply is the original deposit. + #[test] + fn inv_net_equity_equals_initial( + initial in 1i128..=1_000_000_000_000i128, + c_factor in 100_000i128..=9_900_000i128, + n_loops in 0u32..=20u32, + ) { + let (total_supply, total_borrow) = compute_totals(initial, c_factor, n_loops); + prop_assert_eq!( + total_supply - total_borrow, + initial, + "net equity {} != initial {} (c={}, n={})", + total_supply - total_borrow, initial, c_factor, n_loops + ); + } + + // ── Invariant 3 ───────────────────────────────────────────────────────── + // total_supply <= initial * SCALAR_7 / (SCALAR_7 - c_factor). + // This is the geometric-series upper bound (leverage ≤ 1 / (1 - c)). + // We allow +1 for integer truncation rounding. + #[test] + fn inv_leverage_bounded_by_geometric_series( + initial in 1i128..=1_000_000_000i128, // keep small enough that max_supply fits i128 + c_factor in 100_000i128..=9_500_000i128, // cap at 95% so denominator >= 500_000 + n_loops in 0u32..=20u32, + ) { + let (total_supply, _) = compute_totals(initial, c_factor, n_loops); + let denominator = SCALAR_7 - c_factor; + // max_supply = initial × SCALAR_7 / (SCALAR_7 - c_factor) + // Use i128 arithmetic carefully; denominator >= 500_000 and initial <= 1e9, + // so initial * SCALAR_7 <= 1e9 * 1e7 = 1e16, well within i128. + let max_supply = initial * SCALAR_7 / denominator; + prop_assert!( + total_supply <= max_supply + 1, + "total_supply {} exceeds geometric bound {} (initial={}, c={}, n={})", + total_supply, max_supply, initial, c_factor, n_loops + ); + } + + // ── Invariant 4 ───────────────────────────────────────────────────────── + // HF is monotone (non-decreasing) in c_factor for fixed positions and rates. + // A higher collateral factor means the same supply is worth more as + // collateral, so the health factor can only increase. + #[test] + fn inv_hf_monotone_in_c_factor( + b_tokens in 1i128..=1_000_000_000i128, + d_tokens in 1i128..=500_000_000i128, + rate in 1_000_000_000_000i128..=2_000_000_000_000i128, + c_low in 1_000_000i128..=4_999_999i128, + c_high in 5_000_000i128..=9_900_000i128, + ) { + let hf_low = compute_health_factor(b_tokens, d_tokens, rate, rate, c_low); + let hf_high = compute_health_factor(b_tokens, d_tokens, rate, rate, c_high); + if let (Ok(hf_l), Ok(hf_h)) = (hf_low, hf_high) { + prop_assert!( + hf_h >= hf_l, + "HF not monotone: c_low={} → hf={}, c_high={} → hf={}", + c_low, hf_l, c_high, hf_h + ); + } + } + + // ── Invariant 5 ───────────────────────────────────────────────────────── + // compute_step: the supply leg always equals `balance`, regardless of + // c_factor or is_final. + #[test] + fn inv_compute_step_supply_equals_balance( + balance in 0i128..=1_000_000_000_000i128, + c_factor in 0i128..=9_999_999i128, + is_final in proptest::bool::ANY, + ) { + let (supply, _borrow) = compute_step(balance, c_factor, is_final); + prop_assert_eq!( + supply, balance, + "supply {} != balance {} (c={}, final={})", + supply, balance, c_factor, is_final + ); + } + + // ── Invariant 6 ───────────────────────────────────────────────────────── + // compute_step: borrow is exactly 0 on the final step. + // The last iteration only supplies; no further borrowing is needed. + #[test] + fn inv_compute_step_final_borrow_is_zero( + balance in 0i128..=1_000_000_000_000i128, + c_factor in 0i128..=9_999_999i128, + ) { + let (_supply, borrow) = compute_step(balance, c_factor, true); + prop_assert_eq!( + borrow, 0i128, + "final step borrow {} != 0 (balance={}, c={})", + borrow, balance, c_factor + ); + } + + // ── Invariant 7 ───────────────────────────────────────────────────────── + // compute_totals must not panic on extreme inputs. + // checked_mul saturates to 0 on overflow (unwrap_or(0)), so the function + // should always return without panicking. + #[test] + fn inv_no_panic_on_extreme_inputs( + initial in 1i128..=i128::MAX / SCALAR_7, + c_factor in 0i128..=9_999_999i128, + n_loops in 0u32..=20u32, + ) { + // Must not panic + let (total_supply, total_borrow) = compute_totals(initial, c_factor, n_loops); + // Basic sanity: supply and borrow are non-negative + prop_assert!(total_supply >= 0, "total_supply {} < 0", total_supply); + prop_assert!(total_borrow >= 0, "total_borrow {} < 0", total_borrow); + } +} diff --git a/contracts/strategies/router/Cargo.toml b/contracts/strategies/router/Cargo.toml new file mode 100644 index 0000000..90aa74e --- /dev/null +++ b/contracts/strategies/router/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "blend_leverage_router" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "25.3.0" +soroban-fixed-point-math = "1.3.0" + +[dev-dependencies] +soroban-sdk = { version = "25.3.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/contracts/strategies/router/README.md b/contracts/strategies/router/README.md new file mode 100644 index 0000000..32e7340 --- /dev/null +++ b/contracts/strategies/router/README.md @@ -0,0 +1,90 @@ +# PoolRouter + +A Soroban smart contract that acts as an entry point for multi-pool deposits, routing user funds to the registered strategy offering the best net APY. + +## Overview + +Currently, each `BlendLeverageStrategy` is single-pool: one deployment = one Blend pool. `PoolRouter` sits in front of multiple strategies and automatically directs deposits to the highest-yield option, while giving users the option to override the selection. + +``` +User + │ + ▼ +PoolRouter.deposit(amount, from, preferred?) + │ + ├── select_strategy() → Strategy A (APY 8.2%) ◄ chosen (highest) + │ Strategy B (APY 7.1%) + │ Strategy C (APY 5.8%) + │ + └── StrategyA.deposit(amount, router_addr) +``` + +## Architecture + +### Virtual shares + +The router itself is the depositor in each underlying strategy. Strategy contracts track the router's aggregate balance. Internally, the router issues **virtual shares** to each user proportional to their contribution to the router's position in a given strategy. + +``` +User deposits 1 000 USDC into Strategy A (first depositor): + router_balance_before = 0 → user gets 1 000 virtual shares (1:1) + +User 2 deposits 500 USDC into Strategy A (pool has grown to 1 100): + user2_vs = 500 × 1 000 / 1 100 ≈ 454 virtual shares + +User 2's underlying = 454 / 1 454 × 1 600 ≈ 499 USDC +``` + +### APY snapshots + +The router does **not** query Blend pool rates on-chain (this would require re-entrant calls across multiple pools in a single transaction). Instead, the admin (or a trusted keeper that integrates with the B3 rate-snapshot oracle) calls `update_apy(strategy, net_apy_bps)` to write the current net yield for each strategy. The router then reads these snapshots to pick the best pool deterministically. + +## Initialisation + +```rust +PoolRouter::__constructor( + asset: Address, // underlying asset (must match all strategies) + init_args: Vec, + // [0] admin: Address — manages registry and APY updates + // [1..N] strategy: Address — initial strategy addresses (optional) +) +``` + +## Key methods + +| Method | Auth | Description | +|--------|------|-------------| +| `deposit(amount, from, preferred?)` | `from` | Route deposit to best (or preferred) strategy | +| `withdraw(amount, from, to)` | `from` | Withdraw from user's current strategy | +| `balance(from)` | none | User's underlying value across the router | +| `best_strategy()` | none | Preview which strategy would be chosen | +| `add_strategy(strategy)` | admin | Register a new strategy | +| `remove_strategy(strategy)` | admin | De-register a strategy | +| `update_apy(strategy, bps)` | admin | Publish a new APY snapshot | +| `set_admin(new_admin)` | admin | Transfer admin role | +| `strategies()` | none | List all registered strategies with APY | + +## Migration from a single-pool strategy + +Existing depositors in a `BlendLeverageStrategy` can migrate gradually: + +1. **No forced migration** — existing positions in individual strategy contracts are unaffected. The router is purely additive. +2. **Withdraw + re-deposit** — to benefit from automatic routing, a user: + 1. Calls `strategy.withdraw(balance, from, to)` on their current strategy. + 2. Calls `router.deposit(amount, from, None)` to enter the router with automatic best-pool selection. +3. **Front-end guidance** — the DeFindex UI can prompt users with a "Migrate to router" flow once the router holds strategies with competitive APYs. + +## Routing algorithm + +`select_strategy` is deterministic: + +1. Iterate registered strategies and find the one with the highest `net_apy_bps`. +2. Ties are broken by insertion order (earlier registration wins), ensuring stable selection when multiple pools report identical rates. +3. If the user supplies `preferred_strategy`, the router verifies it is registered and uses it directly (skipping the APY comparison). + +## Events + +| Topic | Data | Description | +|-------|------|-------------| +| `(RouterDeposit, from)` | `(strategy, amount, virtual_shares)` | Emitted on each successful deposit | +| `(RouterWithdraw, from)` | `(strategy, amount)` | Emitted on each successful withdrawal | diff --git a/contracts/strategies/router/src/lib.rs b/contracts/strategies/router/src/lib.rs new file mode 100644 index 0000000..b81ddcf --- /dev/null +++ b/contracts/strategies/router/src/lib.rs @@ -0,0 +1,442 @@ +#![no_std] + +mod storage; + +use soroban_sdk::{ + contract, contractclient, contractimpl, contracterror, token::TokenClient, Address, Env, + IntoVal, Symbol, Val, Vec, +}; +use storage::{extend_instance_ttl, StrategyEntry, UserPosition}; + +// ── Router errors ───────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum RouterError { + NoStrategiesRegistered = 1, + StrategyNotFound = 2, + NoPositionFound = 3, + InsufficientBalance = 4, + ArithmeticError = 5, + Unauthorized = 6, + InvalidAmount = 7, + StrategyAlreadyRegistered = 8, +} + +// ── Strategy interface (minimal client) ─────────────────────────────────────── +// +// The router calls these three methods on each underlying strategy contract. +// The signatures match the DeFindexStrategyTrait methods used by all Blend +// leverage strategies; the client is generated from the trait definition here +// so no import of the concrete strategy crate is required. + +#[contractclient(name = "StrategyClient")] +pub trait IStrategy { + fn deposit(e: Env, amount: i128, from: Address) -> i128; + fn withdraw(e: Env, amount: i128, from: Address, to: Address) -> i128; + fn balance(e: Env, from: Address) -> i128; +} + +// ── Router contract ─────────────────────────────────────────────────────────── + +#[contract] +pub struct PoolRouter; + +#[contractimpl] +impl PoolRouter { + /// Initialise the router. + /// + /// init_args layout: + /// [0] admin: Address — manages the strategy registry and APY updates + /// [1..N] strategy: Address — initial strategy addresses (may be empty) + pub fn __constructor(e: Env, asset: Address, init_args: Vec) { + let admin: Address = init_args + .get(0) + .expect("Missing: admin") + .into_val(&e); + + storage::set_admin(&e, &admin); + storage::set_asset(&e, &asset); + + // Register any strategy addresses supplied at construction time. + let mut entries: Vec = Vec::new(&e); + let mut i = 1u32; + while let Some(raw) = init_args.get(i) { + let strategy: Address = raw.into_val(&e); + entries.push_back(StrategyEntry { + address: strategy, + net_apy_bps: 0, + }); + i += 1; + } + storage::set_strategies(&e, &entries); + } + + // ── Strategy registry management (admin-only) ───────────────────────────── + + /// Add a new strategy to the registry with an initial APY of 0 bps. + /// The admin must call `update_apy` to set a meaningful rate before deposits + /// can be routed to this strategy deterministically. + pub fn add_strategy(e: Env, strategy: Address) -> Result<(), RouterError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + + let mut strategies = storage::get_strategies(&e); + for i in 0..strategies.len() { + if strategies.get(i).unwrap().address == strategy { + return Err(RouterError::StrategyAlreadyRegistered); + } + } + strategies.push_back(StrategyEntry { + address: strategy, + net_apy_bps: 0, + }); + storage::set_strategies(&e, &strategies); + Ok(()) + } + + /// Remove a strategy from the registry. + /// Existing user positions in this strategy are unaffected; they can still withdraw. + pub fn remove_strategy(e: Env, strategy: Address) -> Result<(), RouterError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + + let strategies = storage::get_strategies(&e); + let mut updated: Vec = Vec::new(&e); + let mut found = false; + for i in 0..strategies.len() { + let entry = strategies.get(i).unwrap(); + if entry.address == strategy { + found = true; + } else { + updated.push_back(entry); + } + } + if !found { + return Err(RouterError::StrategyNotFound); + } + storage::set_strategies(&e, &updated); + Ok(()) + } + + /// Update the net-APY snapshot for a registered strategy (basis points). + /// This value is read from an off-chain rate oracle (see B3) and written + /// on-chain by the admin or a trusted keeper before each routing decision. + pub fn update_apy( + e: Env, + strategy: Address, + net_apy_bps: i128, + ) -> Result<(), RouterError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + + // Verify strategy is registered and update its snapshot. + let mut strategies = storage::get_strategies(&e); + let mut found = false; + for i in 0..strategies.len() { + let mut entry = strategies.get(i).unwrap(); + if entry.address == strategy { + entry.net_apy_bps = net_apy_bps; + strategies.set(i, entry); + found = true; + break; + } + } + if !found { + return Err(RouterError::StrategyNotFound); + } + storage::set_strategies(&e, &strategies); + storage::set_strategy_apy(&e, &strategy, net_apy_bps); + Ok(()) + } + + /// Transfer the admin role to a new address. + pub fn set_admin(e: Env, new_admin: Address) -> Result<(), RouterError> { + extend_instance_ttl(&e); + let admin = storage::get_admin(&e); + admin.require_auth(); + storage::set_admin(&e, &new_admin); + Ok(()) + } + + // ── Deposit ─────────────────────────────────────────────────────────────── + + /// Deposit `amount` of the underlying asset into the best-APY strategy. + /// + /// The router: + /// 1. Selects the registered strategy with the highest `net_apy_bps`. + /// 2. If `preferred_strategy` is provided and is registered, uses it instead. + /// 3. Transfers `amount` from `from` to the router, then calls the strategy's + /// `deposit` with the router as the depositor. + /// 4. Issues virtual shares to the user proportional to their contribution. + /// + /// Returns the user's underlying value after the deposit. + pub fn deposit( + e: Env, + amount: i128, + from: Address, + preferred_strategy: Option
, + ) -> Result { + extend_instance_ttl(&e); + if amount <= 0 { + return Err(RouterError::InvalidAmount); + } + from.require_auth(); + + // If the user already has a position, route to their current strategy unless + // they explicitly request a different one via preferred_strategy. + let existing_position = storage::get_user_position(&e, &from); + let chosen = if preferred_strategy.is_some() { + Self::select_strategy(&e, preferred_strategy)? + } else if let Some(ref pos) = existing_position { + pos.strategy.clone() + } else { + Self::select_strategy(&e, None)? + }; + + let router_addr = e.current_contract_address(); + let strategy_client = StrategyClient::new(&e, &chosen); + + // Query router's existing balance before deposit for accurate share pricing. + let router_balance_before = strategy_client.balance(&router_addr); + + // Pull asset from user into the router, then forward to the strategy. + let asset = storage::get_asset(&e); + let token = TokenClient::new(&e, &asset); + token.transfer(&from, &router_addr, &amount); + + let router_balance_after = strategy_client.deposit(&amount, &router_addr); + + // Mint virtual shares for the user proportional to their contribution. + let total_vs = storage::get_strategy_virtual_shares(&e, &chosen); + let user_vs = if total_vs == 0 || router_balance_before <= 0 { + // First depositor into this strategy via the router: 1 virtual share = 1 unit. + amount + } else { + // Proportional allocation: user_vs = amount × total_vs / router_balance_before + amount + .checked_mul(total_vs) + .ok_or(RouterError::ArithmeticError)? + .checked_div(router_balance_before) + .ok_or(RouterError::ArithmeticError)? + }; + + let total_vs_after = total_vs + user_vs; + storage::set_strategy_virtual_shares(&e, &chosen, total_vs_after); + + // Each user holds one position in the router (single strategy). + // If they already have a position in the same strategy, accumulate shares. + // To switch strategies, users must withdraw their current position first. + let new_vs = match &existing_position { + Some(pos) if pos.strategy == chosen => pos.virtual_shares + user_vs, + _ => user_vs, + }; + storage::set_user_position( + &e, + &from, + &UserPosition { + strategy: chosen.clone(), + virtual_shares: new_vs, + }, + ); + + e.events().publish( + (Symbol::new(&e, "RouterDeposit"), from.clone()), + (chosen, amount, user_vs), + ); + + // Return underlying value: new_vs / total_vs_after × router_balance_after + let underlying = new_vs + .checked_mul(router_balance_after) + .ok_or(RouterError::ArithmeticError)? + .checked_div(total_vs_after) + .ok_or(RouterError::ArithmeticError)?; + Ok(underlying) + } + + // ── Withdraw ────────────────────────────────────────────────────────────── + + /// Withdraw `amount` of underlying from the user's strategy position. + /// The net equity is sent directly to `to`. + /// + /// Returns the user's remaining underlying balance after withdrawal. + pub fn withdraw( + e: Env, + amount: i128, + from: Address, + to: Address, + ) -> Result { + extend_instance_ttl(&e); + if amount <= 0 { + return Err(RouterError::InvalidAmount); + } + from.require_auth(); + + let position = storage::get_user_position(&e, &from) + .ok_or(RouterError::NoPositionFound)?; + let strategy = position.strategy.clone(); + + let total_vs = storage::get_strategy_virtual_shares(&e, &strategy); + if total_vs == 0 { + return Err(RouterError::InsufficientBalance); + } + + // Determine user's underlying: user_vs / total_vs × router_balance + let strategy_client = StrategyClient::new(&e, &strategy); + let router_addr = e.current_contract_address(); + let router_balance = strategy_client.balance(&router_addr); + + let user_underlying = position + .virtual_shares + .checked_mul(router_balance) + .ok_or(RouterError::ArithmeticError)? + .checked_div(total_vs) + .ok_or(RouterError::ArithmeticError)?; + + if amount > user_underlying { + return Err(RouterError::InsufficientBalance); + } + + // Compute virtual shares to burn: vs_burn = amount × total_vs / router_balance + let vs_burn = amount + .checked_mul(total_vs) + .ok_or(RouterError::ArithmeticError)? + .checked_div(router_balance) + .ok_or(RouterError::ArithmeticError)?; + + // Call strategy.withdraw — sends `amount` equity directly to `to`. + let remaining_router_balance = strategy_client.withdraw(&amount, &router_addr, &to); + + // Update virtual share bookkeeping. + let new_user_vs = position.virtual_shares.saturating_sub(vs_burn); + let new_total_vs = total_vs.saturating_sub(vs_burn); + storage::set_strategy_virtual_shares(&e, &strategy, new_total_vs); + + if new_user_vs == 0 { + storage::remove_user_position(&e, &from); + } else { + storage::set_user_position( + &e, + &from, + &UserPosition { + strategy: strategy.clone(), + virtual_shares: new_user_vs, + }, + ); + } + + e.events().publish( + (Symbol::new(&e, "RouterWithdraw"), from.clone()), + (strategy, amount), + ); + + // Return user's remaining underlying. + if new_user_vs == 0 || new_total_vs == 0 { + return Ok(0); + } + let remaining_user = new_user_vs + .checked_mul(remaining_router_balance) + .ok_or(RouterError::ArithmeticError)? + .checked_div(new_total_vs) + .ok_or(RouterError::ArithmeticError)?; + Ok(remaining_user) + } + + // ── Balance ─────────────────────────────────────────────────────────────── + + /// Return the underlying asset value of `from`'s position across the router. + pub fn balance(e: Env, from: Address) -> Result { + extend_instance_ttl(&e); + let position = match storage::get_user_position(&e, &from) { + Some(p) => p, + None => return Ok(0), + }; + + let total_vs = storage::get_strategy_virtual_shares(&e, &position.strategy); + if total_vs == 0 { + return Ok(0); + } + + let strategy_client = StrategyClient::new(&e, &position.strategy); + let router_balance = strategy_client.balance(&e.current_contract_address()); + + let underlying = position + .virtual_shares + .checked_mul(router_balance) + .ok_or(RouterError::ArithmeticError)? + .checked_div(total_vs) + .ok_or(RouterError::ArithmeticError)?; + Ok(underlying) + } + + // ── View helpers ────────────────────────────────────────────────────────── + + /// Return the strategy that would be chosen for a new deposit (deterministic). + /// Useful for front-ends and for letting users preview before confirming. + pub fn best_strategy(e: Env) -> Result { + Self::select_strategy(&e, None) + } + + /// Return all registered strategies with their APY snapshots. + pub fn strategies(e: Env) -> Vec { + extend_instance_ttl(&e); + storage::get_strategies(&e) + } + + /// Return the current admin address. + pub fn admin(e: Env) -> Address { + extend_instance_ttl(&e); + storage::get_admin(&e) + } + + /// Return the underlying asset address. + pub fn asset(e: Env) -> Address { + extend_instance_ttl(&e); + storage::get_asset(&e) + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + /// Select the strategy with the highest `net_apy_bps`. + /// If `preferred` is Some and is registered, it is used instead (user override). + /// Returns `RouterError::NoStrategiesRegistered` if the registry is empty. + fn select_strategy( + e: &Env, + preferred: Option
, + ) -> Result { + let strategies = storage::get_strategies(e); + if strategies.is_empty() { + return Err(RouterError::NoStrategiesRegistered); + } + + // User override: verify the preferred strategy is registered. + if let Some(pref) = preferred { + for i in 0..strategies.len() { + if strategies.get(i).unwrap().address == pref { + return Ok(pref); + } + } + return Err(RouterError::StrategyNotFound); + } + + // Deterministic best-pick: highest net_apy_bps wins. + // Ties are broken by position in the registry (earlier = preferred), making + // the selection stable across identical snapshots. + let mut best_addr = strategies.get(0).unwrap().address.clone(); + let mut best_apy = strategies.get(0).unwrap().net_apy_bps; + + for i in 1..strategies.len() { + let entry = strategies.get(i).unwrap(); + if entry.net_apy_bps > best_apy { + best_apy = entry.net_apy_bps; + best_addr = entry.address.clone(); + } + } + + Ok(best_addr) + } +} diff --git a/contracts/strategies/router/src/storage.rs b/contracts/strategies/router/src/storage.rs new file mode 100644 index 0000000..3d27c1f --- /dev/null +++ b/contracts/strategies/router/src/storage.rs @@ -0,0 +1,163 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +// ── TTL constants ──────────────────────────────────────────────────────────── + +const ONE_DAY_LEDGERS: u32 = 17_280; +const INSTANCE_BUMP_AMOUNT: u32 = 30 * ONE_DAY_LEDGERS; +const INSTANCE_LIFETIME_THRESHOLD: u32 = INSTANCE_BUMP_AMOUNT - ONE_DAY_LEDGERS; +const PERSISTENT_BUMP_AMOUNT: u32 = 120 * ONE_DAY_LEDGERS; +const PERSISTENT_LIFETIME_THRESHOLD: u32 = PERSISTENT_BUMP_AMOUNT - 20 * ONE_DAY_LEDGERS; + +// ── Data keys ──────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Admin address (manages strategy registry) + Admin, + /// Underlying asset address (must be the same across all strategies) + Asset, + /// Ordered list of registered strategy addresses + Strategies, + /// Net APY snapshot for a strategy (basis points, 100 bps = 1%) + StrategyApy(Address), + /// Total virtual shares the router has issued for a given strategy + StrategyVirtualShares(Address), + /// Per-user position: which strategy their funds are in and their virtual shares + UserPosition(Address), +} + +// ── StrategyEntry ───────────────────────────────────────────────────────────── + +/// A registered strategy with its latest net-APY snapshot. +#[contracttype] +#[derive(Clone, Debug)] +pub struct StrategyEntry { + pub address: Address, + /// Net APY in basis points (e.g. 500 = 5.00%). Updated by the admin via + /// `update_apy`. The router uses this snapshot to pick the best pool; it does + /// NOT query rates on-chain to avoid re-entrant calls. + pub net_apy_bps: i128, +} + +// ── UserPosition ────────────────────────────────────────────────────────────── + +/// Tracks a user's allocation via the router. +#[contracttype] +#[derive(Clone, Debug)] +pub struct UserPosition { + /// The strategy contract this user's funds are deposited in. + pub strategy: Address, + /// The user's virtual-share balance in the router's pool for that strategy. + pub virtual_shares: i128, +} + +// ── 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") +} + +// ── Asset ───────────────────────────────────────────────────────────────────── + +pub fn set_asset(e: &Env, asset: &Address) { + e.storage().instance().set(&DataKey::Asset, asset); +} + +pub fn get_asset(e: &Env) -> Address { + e.storage() + .instance() + .get(&DataKey::Asset) + .expect("Asset not set") +} + +// ── Strategy registry ───────────────────────────────────────────────────────── + +pub fn set_strategies(e: &Env, strategies: &Vec) { + e.storage().instance().set(&DataKey::Strategies, strategies); +} + +pub fn get_strategies(e: &Env) -> Vec { + e.storage() + .instance() + .get(&DataKey::Strategies) + .unwrap_or_else(|| Vec::new(e)) +} + +pub fn set_strategy_apy(e: &Env, strategy: &Address, apy_bps: i128) { + e.storage() + .instance() + .set(&DataKey::StrategyApy(strategy.clone()), &apy_bps); +} + +pub fn get_strategy_apy(e: &Env, strategy: &Address) -> i128 { + e.storage() + .instance() + .get(&DataKey::StrategyApy(strategy.clone())) + .unwrap_or(0) +} + +// ── Virtual shares (router-internal accounting) ─────────────────────────────── + +pub fn get_strategy_virtual_shares(e: &Env, strategy: &Address) -> i128 { + e.storage() + .persistent() + .get(&DataKey::StrategyVirtualShares(strategy.clone())) + .unwrap_or(0i128) +} + +pub fn set_strategy_virtual_shares(e: &Env, strategy: &Address, shares: i128) { + let key = DataKey::StrategyVirtualShares(strategy.clone()); + e.storage().persistent().set(&key, &shares); + e.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + +// ── Per-user position ───────────────────────────────────────────────────────── + +pub fn get_user_position(e: &Env, user: &Address) -> Option { + let key = DataKey::UserPosition(user.clone()); + let pos: Option = e.storage().persistent().get(&key); + if pos.is_some() { + e.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); + } + pos +} + +pub fn set_user_position(e: &Env, user: &Address, position: &UserPosition) { + let key = DataKey::UserPosition(user.clone()); + e.storage().persistent().set(&key, position); + e.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_AMOUNT, + ); +} + +pub fn remove_user_position(e: &Env, user: &Address) { + e.storage() + .persistent() + .remove(&DataKey::UserPosition(user.clone())); +} + +// ── Instance TTL ────────────────────────────────────────────────────────────── + +pub fn extend_instance_ttl(e: &Env) { + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); +}