Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/strategies/blend_leverage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
77 changes: 77 additions & 0 deletions contracts/strategies/blend_leverage/README.md
Original file line number Diff line number Diff line change
@@ -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%
11 changes: 11 additions & 0 deletions contracts/strategies/blend_leverage/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
23 changes: 21 additions & 2 deletions contracts/strategies/blend_leverage/src/leverage.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
Expand Down
57 changes: 56 additions & 1 deletion contracts/strategies/blend_leverage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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};

Expand Down Expand Up @@ -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<Val>) {
let pool: Address = init_args
.get(0)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Address, StrategyError> {
Expand All @@ -127,6 +136,9 @@ impl DeFindexStrategyTrait for BlendLeverageStrategy {
/// 4. Return the depositor's underlying balance
fn deposit(e: Env, amount: i128, from: Address) -> Result<i128, StrategyError> {
extend_instance_ttl(&e);
if storage::is_paused(&e) {
return Err(StrategyError::NotAuthorized);
}
check_positive_amount(amount)?;
from.require_auth();

Expand Down Expand Up @@ -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<Bytes>) -> Result<(), StrategyError> {
extend_instance_ttl(&e);
if storage::is_paused(&e) {
return Err(StrategyError::NotAuthorized);
}

let keeper = storage::get_keeper(&e);
keeper.require_auth();
Expand Down Expand Up @@ -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<Address, StrategyError> {
extend_instance_ttl(&e);
Ok(storage::get_admin(&e))
}
}
28 changes: 28 additions & 0 deletions contracts/strategies/blend_leverage/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub enum DataKey {
Reserves,
VaultPos(Address),
Keeper,
Admin,
Paused,
}

// ── Config ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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) {
Expand Down
Loading