From 399f16fa66444d21832f03b4fd289f6d6dd6abb8 Mon Sep 17 00:00:00 2001 From: JerryIdoko Date: Sun, 21 Jun 2026 00:03:20 +0100 Subject: [PATCH 1/2] feat: flash loan defense during yield harvesting - TWAP oracle for time-weighted average pricing - Delayed state commits with cooldown periods - Price manipulation detection and prevention Closes #219 --- contracts/flash_loan_defense/Cargo.toml | 13 + contracts/flash_loan_defense/src/lib.rs | 315 ++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 contracts/flash_loan_defense/Cargo.toml create mode 100644 contracts/flash_loan_defense/src/lib.rs diff --git a/contracts/flash_loan_defense/Cargo.toml b/contracts/flash_loan_defense/Cargo.toml new file mode 100644 index 00000000..19ea18e6 --- /dev/null +++ b/contracts/flash_loan_defense/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "flash_loan_defense" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/flash_loan_defense/src/lib.rs b/contracts/flash_loan_defense/src/lib.rs new file mode 100644 index 00000000..a10fd57a --- /dev/null +++ b/contracts/flash_loan_defense/src/lib.rs @@ -0,0 +1,315 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, contractevent, Address, Env, Vec, Symbol}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TwapObservation { + pub price: i128, + pub timestamp: u64, + pub cumulative_price: i128, + pub observation_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PriceSnapshot { + pub price: i128, + pub recorded_at: u64, + pub ledger_seq: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct YieldState { + pub total_shares: i128, + pub total_underlying: i128, + pub last_commit_ledger: u32, + pub last_commit_time: u64, + pub pending_shares: i128, + pub pending_underlying: i128, + pub min_hold_ledgers: u32, + pub min_hold_seconds: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum DefDataKey { + Admin, + VaultContract, + YieldAdapter, + TwapObservations(Symbol), + PriceSnapshot(Symbol), + YieldCommit(Symbol), + MinHoldLedgers, + MinHoldSeconds, + TwapWindowSeconds, + IsPaused, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ObservationSubmitted { + pub asset: Symbol, + pub price: i128, + pub timestamp: u64, + pub cumulative_price: i128, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct YieldStateCommitted { + pub asset: Symbol, + pub total_shares: i128, + pub total_underlying: i128, + pub commit_ledger: u32, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ManipulationDetected { + pub asset: Symbol, + pub spot_price: i128, + pub twap_price: i128, + pub deviation_bps: u32, +} + +#[contract] +pub struct FlashLoanDefense; + +#[contractimpl] +impl FlashLoanDefense { + pub fn initialize(env: Env, admin: Address, vault_contract: Address, yield_adapter: Address) { + if env.storage().instance().has(&DefDataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&DefDataKey::Admin, &admin); + env.storage().instance().set(&DefDataKey::VaultContract, &vault_contract); + env.storage().instance().set(&DefDataKey::YieldAdapter, &yield_adapter); + env.storage().instance().set(&DefDataKey::TwapWindowSeconds, &3600u64); + env.storage().instance().set(&DefDataKey::MinHoldLedgers, &5u32); + env.storage().instance().set(&DefDataKey::MinHoldSeconds, &30u64); + env.storage().instance().set(&DefDataKey::IsPaused, &false); + } + + pub fn submit_observation(env: Env, admin: Address, asset: Symbol, price: i128) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + if price <= 0 { + panic!("Price must be positive"); + } + + let now = env.ledger().timestamp(); + let mut observations: Vec = env.storage().instance() + .get(&DefDataKey::TwapObservations(asset.clone())) + .unwrap_or(Vec::new(&env)); + + let last_obs = observations.last(); + let cumulative_price = if let Some(ref prev) = last_obs { + let time_elapsed = now - prev.timestamp; + prev.cumulative_price + prev.price * time_elapsed as i128 + } else { + 0i128 + }; + + let obs_count = if let Some(ref prev) = last_obs { prev.observation_count + 1 } else { 1 }; + + let obs = TwapObservation { + price, + timestamp: now, + cumulative_price, + observation_count: obs_count, + }; + + observations.push_back(obs); + + let window = Self::get_twap_window(&env); + let cutoff = now.saturating_sub(window); + let mut pruned = Vec::new(&env); + for o in observations.iter() { + if o.timestamp >= cutoff || pruned.len() == 0 { + pruned.push_back(o); + } + } + env.storage().instance().set(&DefDataKey::TwapObservations(asset.clone()), &pruned); + + ObservationSubmitted { + asset: asset.clone(), + price, + timestamp: now, + cumulative_price, + }.publish(&env); + } + + pub fn get_twap_price(env: Env, asset: Symbol) -> i128 { + let observations: Vec = env.storage().instance() + .get(&DefDataKey::TwapObservations(asset)) + .unwrap_or(Vec::new(&env)); + + if observations.len() < 2 { + return 0; + } + + let first = observations.first().unwrap(); + let last = observations.last().unwrap(); + let time_elapsed = last.timestamp - first.timestamp; + + if time_elapsed == 0 { + return 0; + } + + let price_delta = last.cumulative_price - first.cumulative_price; + if price_delta <= 0 { + return 0; + } + + price_delta / time_elapsed as i128 + } + + pub fn get_spot_price(env: Env, asset: Symbol) -> i128 { + env.storage().instance() + .get(&DefDataKey::PriceSnapshot(asset)) + .map(|s: PriceSnapshot| s.price) + .unwrap_or(0) + } + + pub fn check_price_manipulation(env: Env, asset: Symbol, spot_price: i128, max_deviation_bps: u32) -> bool { + let twap = Self::get_twap_price(env.clone(), asset.clone()); + if twap == 0 { + return false; + } + if spot_price == 0 { + return false; + } + + let diff = if spot_price > twap { spot_price - twap } else { twap - spot_price }; + let deviation_bps = (diff * 10000) / twap; + + if deviation_bps > max_deviation_bps as i128 { + ManipulationDetected { + asset: asset.clone(), + spot_price, + twap_price: twap, + deviation_bps: deviation_bps as u32, + }.publish(&env); + return true; + } + false + } + + pub fn commit_yield_state(env: Env, admin: Address, asset: Symbol, total_shares: i128, total_underlying: i128) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + let current_ledger = env.ledger().sequence(); + let current_time = env.ledger().timestamp(); + + let mut state: YieldState = env.storage().instance() + .get(&DefDataKey::YieldCommit(asset.clone())) + .unwrap_or(YieldState { + total_shares: 0, + total_underlying: 0, + last_commit_ledger: 0, + last_commit_time: 0, + pending_shares: 0, + pending_underlying: 0, + min_hold_ledgers: Self::get_min_hold_ledgers(&env), + min_hold_seconds: Self::get_min_hold_seconds(&env), + }); + + if total_shares <= 0 || total_underlying <= 0 { + panic!("State values must be positive"); + } + + if state.last_commit_ledger > 0 { + let ledger_diff = current_ledger - state.last_commit_ledger; + let time_diff = current_time - state.last_commit_time; + if ledger_diff < state.min_hold_ledgers || time_diff < state.min_hold_seconds { + panic!("Commit cooldown not elapsed"); + } + } + + let snapshot = PriceSnapshot { + price: if total_shares > 0 { (total_underlying * 10_000_000) / total_shares } else { 10_000_000 }, + recorded_at: current_time, + ledger_seq: current_ledger, + }; + env.storage().instance().set(&DefDataKey::PriceSnapshot(asset.clone()), &snapshot); + + state.total_shares = total_shares; + state.total_underlying = total_underlying; + state.last_commit_ledger = current_ledger; + state.last_commit_time = current_time; + state.pending_shares = 0; + state.pending_underlying = 0; + env.storage().instance().set(&DefDataKey::YieldCommit(asset.clone()), &state); + + YieldStateCommitted { + asset: asset.clone(), + total_shares, + total_underlying, + commit_ledger: current_ledger, + }.publish(&env); + } + + pub fn get_yield_state(env: Env, asset: Symbol) -> YieldState { + env.storage().instance() + .get(&DefDataKey::YieldCommit(asset)) + .unwrap_or(YieldState { + total_shares: 0, + total_underlying: 0, + last_commit_ledger: 0, + last_commit_time: 0, + pending_shares: 0, + pending_underlying: 0, + min_hold_ledgers: 0, + min_hold_seconds: 0, + }) + } + + pub fn set_min_hold_ledgers(env: Env, admin: Address, ledgers: u32) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&DefDataKey::MinHoldLedgers, &ledgers); + } + + pub fn get_min_hold_ledgers(env: &Env) -> u32 { + env.storage().instance().get(&DefDataKey::MinHoldLedgers).unwrap_or(5u32) + } + + pub fn set_min_hold_seconds(env: Env, admin: Address, seconds: u64) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&DefDataKey::MinHoldSeconds, &seconds); + } + + pub fn get_min_hold_seconds(env: &Env) -> u64 { + env.storage().instance().get(&DefDataKey::MinHoldSeconds).unwrap_or(30u64) + } + + pub fn set_twap_window(env: Env, admin: Address, window_seconds: u64) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&DefDataKey::TwapWindowSeconds, &window_seconds); + } + + pub fn get_twap_window(env: &Env) -> u64 { + env.storage().instance().get(&DefDataKey::TwapWindowSeconds).unwrap_or(3600u64) + } + + pub fn set_pause(env: Env, admin: Address, paused: bool) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&DefDataKey::IsPaused, &paused); + } + + fn require_admin(env: &Env, admin: &Address) { + let stored: Address = env.storage().instance().get(&DefDataKey::Admin).expect("Admin not set"); + if stored != *admin { + admin.require_auth(); + } + } + + fn require_not_paused(env: &Env) { + if env.storage().instance().get(&DefDataKey::IsPaused).unwrap_or(false) { + panic!("Contract is paused"); + } + } +} From d680d1b9300efe53b268f2f2db80c40a5b108330 Mon Sep 17 00:00:00 2001 From: JerryIdoko Date: Sun, 21 Jun 2026 00:03:30 +0100 Subject: [PATCH 2/2] Add yield circuit breaker, collateral buffer, and borrowing adapter contracts --- Cargo.toml | 4 + contracts/collateral_buffer/Cargo.toml | 13 + contracts/collateral_buffer/src/lib.rs | 410 ++++++++++++++++++ .../Cargo.toml | 13 + .../src/lib.rs | 374 ++++++++++++++++ contracts/yield_circuit_breaker/Cargo.toml | 13 + contracts/yield_circuit_breaker/src/lib.rs | 261 +++++++++++ 7 files changed, 1088 insertions(+) create mode 100644 contracts/collateral_buffer/Cargo.toml create mode 100644 contracts/collateral_buffer/src/lib.rs create mode 100644 contracts/collateralized_borrowing_adapter/Cargo.toml create mode 100644 contracts/collateralized_borrowing_adapter/src/lib.rs create mode 100644 contracts/yield_circuit_breaker/Cargo.toml create mode 100644 contracts/yield_circuit_breaker/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index a320ea98..153d8ff6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,10 @@ members = [ "contracts/vesting_vault", "contracts/deposit_to_yield_adapter", "contracts/insurance_treasury", + "contracts/flash_loan_defense", + "contracts/yield_circuit_breaker", + "contracts/collateral_buffer", + "contracts/collateralized_borrowing_adapter", ] resolver = "2" diff --git a/contracts/collateral_buffer/Cargo.toml b/contracts/collateral_buffer/Cargo.toml new file mode 100644 index 00000000..677d7a81 --- /dev/null +++ b/contracts/collateral_buffer/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "collateral_buffer" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/collateral_buffer/src/lib.rs b/contracts/collateral_buffer/src/lib.rs new file mode 100644 index 00000000..784da620 --- /dev/null +++ b/contracts/collateral_buffer/src/lib.rs @@ -0,0 +1,410 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, contractevent, Address, Env, Vec, token}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AMMPoolPosition { + pub pool_address: Address, + pub token_a: Address, + pub token_b: Address, + pub base_token: Address, + pub deposited_base: i128, + pub deposited_other: i128, + pub pool_shares: i128, + pub deposited_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CollateralBufferConfig { + pub buffer_bps: u32, + pub dao_admin: Address, + pub max_pool_allocation_bps: u32, + pub rebalance_threshold_bps: u32, + pub min_buffer_seconds: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VaultBufferState { + pub vault_id: u64, + pub total_promised_base: i128, + pub total_collateral_value: i128, + pub buffer_amount: i128, + pub last_rebalance: u64, + pub positions: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum BufferDataKey { + Admin, + VaultContract, + BufferConfig, + VaultState(u64), + WhitelistedPool(Address), + PoolCounter, + IsPaused, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct BufferConfigured { + pub buffer_bps: u32, + pub dao_admin: Address, + pub configured_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CollateralDeposited { + pub vault_id: u64, + pub pool_address: Address, + pub base_amount: i128, + pub pool_shares: i128, + pub deposited_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct BufferRebalanced { + pub vault_id: u64, + pub total_promised: i128, + pub total_collateral: i128, + pub buffer_amount: i128, + pub buffer_pct: u32, + pub rebalanced_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct InsufficientBuffer { + pub vault_id: u64, + pub total_promised: i128, + pub total_collateral: i128, + pub shortfall: i128, + pub detected_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct PositionWithdrawn { + pub vault_id: u64, + pub pool_address: Address, + pub base_withdrawn: i128, + pub other_withdrawn: i128, + pub withdrawn_at: u64, +} + +pub const DEFAULT_BUFFER_BPS: u32 = 500; +pub const MAX_BUFFER_BPS: u32 = 2000; +pub const BASIS_POINTS_DENOM: u32 = 10000; + +#[contract] +pub struct CollateralBuffer; + +#[contractimpl] +impl CollateralBuffer { + pub fn initialize(env: Env, admin: Address, vault_contract: Address, dao_admin: Address) { + if env.storage().instance().has(&BufferDataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + + env.storage().instance().set(&BufferDataKey::Admin, &admin); + env.storage().instance().set(&BufferDataKey::VaultContract, &vault_contract); + env.storage().instance().set(&BufferDataKey::PoolCounter, &0u64); + env.storage().instance().set(&BufferDataKey::IsPaused, &false); + + let config = CollateralBufferConfig { + buffer_bps: DEFAULT_BUFFER_BPS, + dao_admin, + max_pool_allocation_bps: 3000, + rebalance_threshold_bps: 100, + min_buffer_seconds: 86400, + }; + env.storage().instance().set(&BufferDataKey::BufferConfig, &config); + + let stored_admin: Address = env.storage().instance().get(&BufferDataKey::Admin).unwrap(); + BufferConfigured { + buffer_bps: DEFAULT_BUFFER_BPS, + dao_admin: stored_admin, + configured_at: env.ledger().timestamp(), + }.publish(&env); + } + + pub fn whitelist_pool(env: Env, admin: Address, pool_address: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&BufferDataKey::WhitelistedPool(pool_address), &true); + + let mut counter: u64 = env.storage().instance().get(&BufferDataKey::PoolCounter).unwrap_or(0); + counter += 1; + env.storage().instance().set(&BufferDataKey::PoolCounter, &counter); + } + + pub fn is_pool_whitelisted(env: Env, pool_address: Address) -> bool { + env.storage().instance().get(&BufferDataKey::WhitelistedPool(pool_address)).unwrap_or(false) + } + + pub fn deposit_to_amm_pool( + env: Env, + admin: Address, + vault_id: u64, + pool_address: Address, + base_token: Address, + other_token: Address, + base_amount: i128, + other_amount: i128, + ) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + if !Self::is_pool_whitelisted(env.clone(), pool_address.clone()) { + panic!("Pool not whitelisted"); + } + if base_amount <= 0 || other_amount <= 0 { + panic!("Amounts must be positive"); + } + + let mut vault_state: VaultBufferState = env.storage().instance() + .get(&BufferDataKey::VaultState(vault_id)) + .unwrap_or(VaultBufferState { + vault_id, + total_promised_base: 0, + total_collateral_value: 0, + buffer_amount: 0, + last_rebalance: 0, + positions: Vec::new(&env), + }); + + let pool_shares = Self::calculate_pool_shares(base_amount, other_amount); + + let position = AMMPoolPosition { + pool_address: pool_address.clone(), + token_a: base_token.clone(), + token_b: other_token.clone(), + base_token: base_token.clone(), + deposited_base: base_amount, + deposited_other: other_amount, + pool_shares, + deposited_at: env.ledger().timestamp(), + }; + + vault_state.total_promised_base += base_amount; + vault_state.total_collateral_value += base_amount + other_amount; + vault_state.buffer_amount += other_amount; + vault_state.positions.push_back(position); + + env.storage().instance().set(&BufferDataKey::VaultState(vault_id), &vault_state); + + let vault_contract: Address = env.storage().instance().get(&BufferDataKey::VaultContract).expect("Vault contract not set"); + let base_token_client = token::Client::new(&env, &base_token); + base_token_client.transfer(&vault_contract, &env.current_contract_address(), &base_amount); + + let other_token_client = token::Client::new(&env, &other_token); + other_token_client.transfer(&vault_contract, &env.current_contract_address(), &other_amount); + + CollateralDeposited { + vault_id, + pool_address: pool_address.clone(), + base_amount, + pool_shares, + deposited_at: env.ledger().timestamp(), + }.publish(&env); + + Self::check_buffer_health(&env, vault_id); + } + + pub fn withdraw_from_pool( + env: Env, + admin: Address, + vault_id: u64, + position_index: u32, + ) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + let mut vault_state: VaultBufferState = env.storage().instance() + .get(&BufferDataKey::VaultState(vault_id)) + .expect("Vault state not found"); + + if position_index >= vault_state.positions.len() { + panic!("Position not found"); + } + + let position = vault_state.positions.get(position_index).unwrap(); + + let (base_withdrawn, other_withdrawn) = Self::withdraw_from_amm(&position); + + vault_state.total_collateral_value -= base_withdrawn + other_withdrawn; + vault_state.buffer_amount -= other_withdrawn; + vault_state.total_promised_base -= base_withdrawn; + vault_state.positions.remove(position_index); + + env.storage().instance().set(&BufferDataKey::VaultState(vault_id), &vault_state); + + let vault_contract: Address = env.storage().instance().get(&BufferDataKey::VaultContract).expect("Vault contract not set"); + let base_token_client = token::Client::new(&env, &position.base_token); + base_token_client.transfer(&env.current_contract_address(), &vault_contract, &base_withdrawn); + + PositionWithdrawn { + vault_id, + pool_address: position.pool_address, + base_withdrawn, + other_withdrawn, + withdrawn_at: env.ledger().timestamp(), + }.publish(&env); + } + + pub fn rebalance_buffer(env: Env, admin: Address, vault_id: u64) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + let config: CollateralBufferConfig = env.storage().instance() + .get(&BufferDataKey::BufferConfig) + .expect("Buffer config not set"); + let mut vault_state: VaultBufferState = env.storage().instance() + .get(&BufferDataKey::VaultState(vault_id)) + .expect("Vault state not found"); + + let now = env.ledger().timestamp(); + if now < vault_state.last_rebalance + config.min_buffer_seconds { + panic!("Rebalance cooldown not elapsed"); + } + + let total_collateral = vault_state.total_collateral_value; + + if total_collateral < vault_state.total_promised_base { + InsufficientBuffer { + vault_id, + total_promised: vault_state.total_promised_base, + total_collateral, + shortfall: vault_state.total_promised_base - total_collateral, + detected_at: now, + }.publish(&env); + panic!("Collateral value less than promised base"); + } + + vault_state.last_rebalance = now; + env.storage().instance().set(&BufferDataKey::VaultState(vault_id), &vault_state); + + let buffer_pct = if total_collateral > 0 { + ((vault_state.buffer_amount * BASIS_POINTS_DENOM as i128) / total_collateral) as u32 + } else { + 0 + }; + + BufferRebalanced { + vault_id, + total_promised: vault_state.total_promised_base, + total_collateral, + buffer_amount: vault_state.buffer_amount, + buffer_pct, + rebalanced_at: now, + }.publish(&env); + } + + pub fn check_buffer_health(env: &Env, vault_id: u64) -> bool { + let config: CollateralBufferConfig = env.storage().instance() + .get(&BufferDataKey::BufferConfig) + .expect("Buffer config not set"); + if let Some(vault_state) = env.storage().instance().get::<_, VaultBufferState>(&BufferDataKey::VaultState(vault_id)) { + let required_buffer = (vault_state.total_promised_base * config.buffer_bps as i128) / BASIS_POINTS_DENOM as i128; + let total_required = vault_state.total_promised_base + required_buffer; + + if vault_state.total_collateral_value < total_required { + InsufficientBuffer { + vault_id, + total_promised: vault_state.total_promised_base, + total_collateral: vault_state.total_collateral_value, + shortfall: total_required - vault_state.total_collateral_value, + detected_at: env.ledger().timestamp(), + }.publish(env); + return false; + } + true + } else { + true + } + } + + pub fn get_vault_buffer_state(env: Env, vault_id: u64) -> VaultBufferState { + env.storage().instance() + .get(&BufferDataKey::VaultState(vault_id)) + .unwrap_or(VaultBufferState { + vault_id, + total_promised_base: 0, + total_collateral_value: 0, + buffer_amount: 0, + last_rebalance: 0, + positions: Vec::new(&env), + }) + } + + pub fn guaranteed_withdrawable(env: Env, vault_id: u64) -> i128 { + let vault_state = Self::get_vault_buffer_state(env, vault_id); + let required_buffer = (vault_state.total_promised_base * DEFAULT_BUFFER_BPS as i128) / BASIS_POINTS_DENOM as i128; + if vault_state.total_collateral_value >= vault_state.total_promised_base + required_buffer { + vault_state.total_promised_base + } else if vault_state.total_collateral_value >= vault_state.total_promised_base { + vault_state.total_promised_base - (vault_state.total_collateral_value - vault_state.total_promised_base) + } else { + vault_state.total_collateral_value + } + } + + pub fn set_buffer_bps(env: Env, admin: Address, buffer_bps: u32) { + Self::require_admin(&env, &admin); + if buffer_bps > MAX_BUFFER_BPS { + panic!("Buffer exceeds maximum"); + } + let mut config: CollateralBufferConfig = env.storage().instance() + .get(&BufferDataKey::BufferConfig) + .expect("Buffer config not set"); + config.buffer_bps = buffer_bps; + env.storage().instance().set(&BufferDataKey::BufferConfig, &config); + } + + pub fn set_dao_admin(env: Env, admin: Address, new_dao_admin: Address) { + Self::require_admin(&env, &admin); + let mut config: CollateralBufferConfig = env.storage().instance() + .get(&BufferDataKey::BufferConfig) + .expect("Buffer config not set"); + config.dao_admin = new_dao_admin; + env.storage().instance().set(&BufferDataKey::BufferConfig, &config); + } + + pub fn get_config(env: Env) -> CollateralBufferConfig { + env.storage().instance().get(&BufferDataKey::BufferConfig).expect("Buffer config not set") + } + + pub fn set_pause(env: Env, admin: Address, paused: bool) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&BufferDataKey::IsPaused, &paused); + } + + fn calculate_pool_shares(base_amount: i128, other_amount: i128) -> i128 { + base_amount + other_amount + } + + fn withdraw_from_amm(position: &AMMPoolPosition) -> (i128, i128) { + let total = position.pool_shares; + let il_loss = (total * 5i128) / 1000i128; + let base_withdrawn = position.deposited_base - il_loss; + let other_withdrawn = position.deposited_other; + (base_withdrawn, other_withdrawn) + } + + fn require_admin(env: &Env, admin: &Address) { + let stored: Address = env.storage().instance().get(&BufferDataKey::Admin).expect("Admin not set"); + if stored != *admin { + admin.require_auth(); + } + } + + fn require_not_paused(env: &Env) { + if env.storage().instance().get(&BufferDataKey::IsPaused).unwrap_or(false) { + panic!("Contract is paused"); + } + } +} diff --git a/contracts/collateralized_borrowing_adapter/Cargo.toml b/contracts/collateralized_borrowing_adapter/Cargo.toml new file mode 100644 index 00000000..7707a530 --- /dev/null +++ b/contracts/collateralized_borrowing_adapter/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "collateralized_borrowing_adapter" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/collateralized_borrowing_adapter/src/lib.rs b/contracts/collateralized_borrowing_adapter/src/lib.rs new file mode 100644 index 00000000..0d682b81 --- /dev/null +++ b/contracts/collateralized_borrowing_adapter/src/lib.rs @@ -0,0 +1,374 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, contractevent, Address, Env, Symbol, Vec, IntoVal}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct FutureUnlockInfo { + pub vault_id: u64, + pub beneficiary: Address, + pub total_unvested: i128, + pub vested_unclaimed: i128, + pub guaranteed_future_unlocks: i128, + pub current_time: u64, + pub end_time: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CollateralizedPosition { + pub vault_id: u64, + pub beneficiary: Address, + pub lending_protocol: Address, + pub loan_amount: i128, + pub collateral_value: i128, + pub liquidation_threshold_bps: u32, + pub created_at: u64, + pub is_active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum AdapterDataKey { + Admin, + VaultContract, + CollateralBridge, + LiquidationThresholdBps, + MaxLtvBps, + IsPaused, + Position(u64), + PositionCounter, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct UnlockScheduleVerified { + pub vault_id: u64, + pub beneficiary: Address, + pub total_unvested: i128, + pub guaranteed_future_unlocks: i128, + pub verified_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CollateralPositionOpened { + pub vault_id: u64, + pub beneficiary: Address, + pub lending_protocol: Address, + pub loan_amount: i128, + pub collateral_value: i128, + pub created_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CollateralPositionClosed { + pub vault_id: u64, + pub beneficiary: Address, + pub closed_at: u64, +} + +pub const MAX_LTV_BPS: u32 = 6000; +pub const LIQUIDATION_THRESHOLD_BPS: u32 = 8000; +pub const BASIS_POINTS_DENOM: u32 = 10000; + +#[contract] +pub struct CollateralizedBorrowingAdapter; + +#[contractimpl] +impl CollateralizedBorrowingAdapter { + pub fn initialize(env: Env, admin: Address, vault_contract: Address, collateral_bridge: Address) { + if env.storage().instance().has(&AdapterDataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + + env.storage().instance().set(&AdapterDataKey::Admin, &admin); + env.storage().instance().set(&AdapterDataKey::VaultContract, &vault_contract); + env.storage().instance().set(&AdapterDataKey::CollateralBridge, &collateral_bridge); + env.storage().instance().set(&AdapterDataKey::LiquidationThresholdBps, &LIQUIDATION_THRESHOLD_BPS); + env.storage().instance().set(&AdapterDataKey::MaxLtvBps, &MAX_LTV_BPS); + env.storage().instance().set(&AdapterDataKey::PositionCounter, &0u64); + env.storage().instance().set(&AdapterDataKey::IsPaused, &false); + } + + pub fn verify_future_unlocks( + env: Env, + vault_id: u64, + beneficiary: Address, + total_amount: i128, + released_amount: i128, + start_time: u64, + end_time: u64, + ) -> FutureUnlockInfo { + let now = env.ledger().timestamp(); + + let total_unvested = if total_amount > released_amount { + total_amount - released_amount + } else { + 0 + }; + + let vesting_duration = if end_time > start_time { end_time - start_time } else { 1 }; + + let vested_unclaimed = if now > start_time { + let elapsed = if now > end_time { vesting_duration } else { now - start_time }; + let vested = (total_amount * elapsed as i128) / vesting_duration as i128; + let claimed = released_amount; + if vested > claimed { vested - claimed } else { 0 } + } else { + 0 + }; + + let guaranteed_future_unlocks = if total_unvested > 0 { total_unvested } else { 0 }; + + let info = FutureUnlockInfo { + vault_id, + beneficiary: beneficiary.clone(), + total_unvested, + vested_unclaimed, + guaranteed_future_unlocks, + current_time: now, + end_time, + }; + + UnlockScheduleVerified { + vault_id, + beneficiary: beneficiary.clone(), + total_unvested, + guaranteed_future_unlocks, + verified_at: now, + }.publish(&env); + + info + } + + pub fn verify_collateral_for_loan( + env: Env, + vault_id: u64, + beneficiary: Address, + total_amount: i128, + released_amount: i128, + start_time: u64, + end_time: u64, + desired_loan_amount: i128, + stablecoin_price: i128, + ) -> bool { + let unlock_info = Self::verify_future_unlocks( + env.clone(), + vault_id, + beneficiary.clone(), + total_amount, + released_amount, + start_time, + end_time, + ); + + if unlock_info.guaranteed_future_unlocks <= 0 { + panic!("No future unlocks available"); + } + + let max_ltv_bps: u32 = env.storage().instance().get(&AdapterDataKey::MaxLtvBps).unwrap_or(MAX_LTV_BPS); + let max_loan = (unlock_info.guaranteed_future_unlocks * max_ltv_bps as i128) / BASIS_POINTS_DENOM as i128; + + let max_loan_in_stablecoin = if stablecoin_price > 0 { + (max_loan * stablecoin_price) / 10_000_000i128 + } else { + max_loan + }; + + max_loan_in_stablecoin >= desired_loan_amount + } + + pub fn calculate_max_borrowable( + env: Env, + vault_id: u64, + beneficiary: Address, + total_amount: i128, + released_amount: i128, + start_time: u64, + end_time: u64, + stablecoin_price: i128, + ) -> i128 { + let unlock_info = Self::verify_future_unlocks( + env.clone(), + vault_id, + beneficiary, + total_amount, + released_amount, + start_time, + end_time, + ); + let max_ltv_bps: u32 = env.storage().instance().get(&AdapterDataKey::MaxLtvBps).unwrap_or(MAX_LTV_BPS); + let max_loan = (unlock_info.guaranteed_future_unlocks * max_ltv_bps as i128) / BASIS_POINTS_DENOM as i128; + + if stablecoin_price > 0 { + (max_loan * stablecoin_price) / 10_000_000i128 + } else { + max_loan + } + } + + pub fn open_collateralized_position( + env: Env, + admin: Address, + vault_id: u64, + beneficiary: Address, + lending_protocol: Address, + loan_amount: i128, + collateral_value: i128, + ) -> u64 { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + if loan_amount <= 0 || collateral_value <= 0 { + panic!("Amounts must be positive"); + } + + let max_ltv_bps: u32 = env.storage().instance().get(&AdapterDataKey::MaxLtvBps).unwrap_or(MAX_LTV_BPS); + let max_loan = (collateral_value * max_ltv_bps as i128) / BASIS_POINTS_DENOM as i128; + if loan_amount > max_loan { + panic!("Loan exceeds max LTV"); + } + + let position_id = Self::increment_position_counter(&env); + let position = CollateralizedPosition { + vault_id, + beneficiary: beneficiary.clone(), + lending_protocol: lending_protocol.clone(), + loan_amount, + collateral_value, + liquidation_threshold_bps: Self::get_liquidation_threshold(&env), + created_at: env.ledger().timestamp(), + is_active: true, + }; + + env.storage().instance().set(&AdapterDataKey::Position(position_id), &position); + + let collateral_bridge: Address = env.storage().instance() + .get(&AdapterDataKey::CollateralBridge) + .expect("Collateral bridge not set"); + + let bridge_args = Vec::from_array(&env, [ + vault_id.into_val(&env), + lending_protocol.clone().into_val(&env), + collateral_value.into_val(&env), + loan_amount.into_val(&env), + 500u32.into_val(&env), + (env.ledger().timestamp() + 365 * 86400).into_val(&env), + ]); + let _lien_id: u64 = env.invoke_contract( + &collateral_bridge, + &Symbol::new(&env, "create_lien"), + bridge_args, + ); + + CollateralPositionOpened { + vault_id, + beneficiary: beneficiary.clone(), + lending_protocol: lending_protocol.clone(), + loan_amount, + collateral_value, + created_at: env.ledger().timestamp(), + }.publish(&env); + + position_id + } + + pub fn close_collateralized_position(env: Env, admin: Address, position_id: u64) { + Self::require_admin(&env, &admin); + Self::require_not_paused(&env); + + let mut position: CollateralizedPosition = env.storage().instance() + .get(&AdapterDataKey::Position(position_id)) + .expect("Position not found"); + + if !position.is_active { + panic!("Position already closed"); + } + + position.is_active = false; + env.storage().instance().set(&AdapterDataKey::Position(position_id), &position); + + CollateralPositionClosed { + vault_id: position.vault_id, + beneficiary: position.beneficiary, + closed_at: env.ledger().timestamp(), + }.publish(&env); + } + + pub fn get_position(env: Env, position_id: u64) -> CollateralizedPosition { + env.storage().instance() + .get(&AdapterDataKey::Position(position_id)) + .expect("Position not found") + } + + pub fn get_vault_schedule( + env: Env, + vault_id: u64, + beneficiary: Address, + total_amount: i128, + released_amount: i128, + start_time: u64, + end_time: u64, + ) -> FutureUnlockInfo { + Self::verify_future_unlocks( + env, + vault_id, + beneficiary, + total_amount, + released_amount, + start_time, + end_time, + ) + } + + pub fn set_liquidation_threshold(env: Env, admin: Address, threshold_bps: u32) { + Self::require_admin(&env, &admin); + if threshold_bps > BASIS_POINTS_DENOM { + panic!("Threshold exceeds 100%"); + } + env.storage().instance().set(&AdapterDataKey::LiquidationThresholdBps, &threshold_bps); + } + + pub fn get_liquidation_threshold(env: &Env) -> u32 { + env.storage().instance().get(&AdapterDataKey::LiquidationThresholdBps).unwrap_or(LIQUIDATION_THRESHOLD_BPS) + } + + pub fn set_max_ltv(env: Env, admin: Address, max_ltv_bps: u32) { + Self::require_admin(&env, &admin); + if max_ltv_bps > BASIS_POINTS_DENOM { + panic!("Max LTV exceeds 100%"); + } + env.storage().instance().set(&AdapterDataKey::MaxLtvBps, &max_ltv_bps); + } + + pub fn get_max_ltv(env: Env) -> u32 { + env.storage().instance().get(&AdapterDataKey::MaxLtvBps).unwrap_or(MAX_LTV_BPS) + } + + pub fn set_pause(env: Env, admin: Address, paused: bool) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&AdapterDataKey::IsPaused, &paused); + } + + fn increment_position_counter(env: &Env) -> u64 { + let count: u64 = env.storage().instance().get(&AdapterDataKey::PositionCounter).unwrap_or(0); + let new_count = count + 1; + env.storage().instance().set(&AdapterDataKey::PositionCounter, &new_count); + new_count + } + + fn require_admin(env: &Env, admin: &Address) { + let stored: Address = env.storage().instance().get(&AdapterDataKey::Admin).expect("Admin not set"); + if stored != *admin { + admin.require_auth(); + } + } + + fn require_not_paused(env: &Env) { + if env.storage().instance().get(&AdapterDataKey::IsPaused).unwrap_or(false) { + panic!("Contract is paused"); + } + } +} diff --git a/contracts/yield_circuit_breaker/Cargo.toml b/contracts/yield_circuit_breaker/Cargo.toml new file mode 100644 index 00000000..c3fb7a94 --- /dev/null +++ b/contracts/yield_circuit_breaker/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "yield_circuit_breaker" +version = "0.0.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/yield_circuit_breaker/src/lib.rs b/contracts/yield_circuit_breaker/src/lib.rs new file mode 100644 index 00000000..ef952a4a --- /dev/null +++ b/contracts/yield_circuit_breaker/src/lib.rs @@ -0,0 +1,261 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, contracttype, contractevent, Address, Env, Symbol, String, Vec, IntoVal}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct YieldProtocolConfig { + pub protocol: Address, + pub asset: Address, + pub apy_bps: u32, + pub last_checked_at: u64, + pub gas_cost_harvest: i128, + pub min_apy_threshold_bps: u32, + pub circuit_breaker_active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CircuitBreakerConfig { + pub min_apy_threshold_bps: u32, + pub max_gas_cost: i128, + pub cooldown_seconds: u64, + pub auto_withdraw: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum BreakerDataKey { + Admin, + YieldAdapter, + VaultContract, + InsuranceTreasury, + ProtocolConfig(Address, Address), + GlobalConfig, + BreakerHistory(Address, Address), + IsPaused, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ProtocolRiskUpdated { + pub protocol: Address, + pub asset: Address, + pub apy_bps: u32, + pub gas_cost: i128, + pub checked_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CircuitBreakerTripped { + pub protocol: Address, + pub asset: Address, + pub apy_bps: u32, + pub threshold_bps: u32, + pub gas_cost: i128, + pub reason: String, + pub tripped_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct EmergencyWithdrawalTriggered { + pub protocol: Address, + pub asset: Address, + pub vault_id: u64, + pub amount: i128, + pub triggered_at: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct CircuitBreakerReset { + pub protocol: Address, + pub asset: Address, + pub reset_by: Address, + pub reset_at: u64, +} + +#[contract] +pub struct YieldCircuitBreaker; + +#[contractimpl] +impl YieldCircuitBreaker { + pub fn initialize(env: Env, admin: Address, yield_adapter: Address, vault_contract: Address, insurance_treasury: Address) { + if env.storage().instance().has(&BreakerDataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&BreakerDataKey::Admin, &admin); + env.storage().instance().set(&BreakerDataKey::YieldAdapter, &yield_adapter); + env.storage().instance().set(&BreakerDataKey::VaultContract, &vault_contract); + env.storage().instance().set(&BreakerDataKey::InsuranceTreasury, &insurance_treasury); + env.storage().instance().set(&BreakerDataKey::IsPaused, &false); + + let default_config = CircuitBreakerConfig { + min_apy_threshold_bps: 50, + max_gas_cost: 100_000_000i128, + cooldown_seconds: 86400, + auto_withdraw: true, + }; + env.storage().instance().set(&BreakerDataKey::GlobalConfig, &default_config); + } + + pub fn register_protocol(env: Env, admin: Address, protocol: Address, asset: Address, min_apy_bps: u32) { + Self::require_admin(&env, &admin); + + let config = YieldProtocolConfig { + protocol: protocol.clone(), + asset: asset.clone(), + apy_bps: 0, + last_checked_at: 0, + gas_cost_harvest: 0, + min_apy_threshold_bps: min_apy_bps, + circuit_breaker_active: false, + }; + env.storage().instance().set(&BreakerDataKey::ProtocolConfig(protocol, asset), &config); + } + + pub fn update_protocol_apy(env: Env, admin: Address, protocol: Address, asset: Address, apy_bps: u32, gas_cost: i128) { + Self::require_admin(&env, &admin); + + let mut config: YieldProtocolConfig = Self::get_protocol_config(env.clone(), protocol.clone(), asset.clone()); + let now = env.ledger().timestamp(); + + config.apy_bps = apy_bps; + config.last_checked_at = now; + config.gas_cost_harvest = gas_cost; + + let global: CircuitBreakerConfig = env.storage().instance() + .get(&BreakerDataKey::GlobalConfig) + .expect("Global config not set"); + + let threshold = if config.min_apy_threshold_bps > 0 { config.min_apy_threshold_bps } else { global.min_apy_threshold_bps }; + + if apy_bps < threshold || gas_cost > global.max_gas_cost { + config.circuit_breaker_active = true; + env.storage().instance().set(&BreakerDataKey::ProtocolConfig(protocol.clone(), asset.clone()), &config); + + let mut reason = String::from_str(&env, "APY below threshold"); + if gas_cost > global.max_gas_cost { + reason = String::from_str(&env, "Gas cost exceeds maximum"); + } + if apy_bps < threshold && gas_cost > global.max_gas_cost { + reason = String::from_str(&env, "APY below threshold and gas cost exceeds maximum"); + } + + CircuitBreakerTripped { + protocol: protocol.clone(), + asset: asset.clone(), + apy_bps, + threshold_bps: threshold, + gas_cost, + reason: reason.clone(), + tripped_at: now, + }.publish(&env); + + if global.auto_withdraw { + Self::trigger_emergency_withdraw(&env, &protocol, &asset, now); + } + } else { + env.storage().instance().set(&BreakerDataKey::ProtocolConfig(protocol.clone(), asset.clone()), &config); + } + + ProtocolRiskUpdated { + protocol: protocol.clone(), + asset: asset.clone(), + apy_bps, + gas_cost, + checked_at: now, + }.publish(&env); + } + + pub fn trigger_emergency_withdraw_all(env: Env, admin: Address, protocol: Address, asset: Address) { + Self::require_admin(&env, &admin); + + let now = env.ledger().timestamp(); + Self::trigger_emergency_withdraw(&env, &protocol, &asset, now); + } + + pub fn check_circuit_breaker(env: Env, protocol: Address, asset: Address) -> bool { + let config = Self::get_protocol_config(env, protocol, asset); + config.circuit_breaker_active + } + + pub fn reset_circuit_breaker(env: Env, admin: Address, protocol: Address, asset: Address) { + Self::require_admin(&env, &admin); + + let mut config = Self::get_protocol_config(env.clone(), protocol.clone(), asset.clone()); + config.circuit_breaker_active = false; + env.storage().instance().set(&BreakerDataKey::ProtocolConfig(protocol.clone(), asset.clone()), &config); + + CircuitBreakerReset { + protocol: protocol.clone(), + asset: asset.clone(), + reset_by: admin.clone(), + reset_at: env.ledger().timestamp(), + }.publish(&env); + } + + pub fn update_global_config(env: Env, admin: Address, min_apy_threshold_bps: u32, max_gas_cost: i128, cooldown_seconds: u64, auto_withdraw: bool) { + Self::require_admin(&env, &admin); + + let config = CircuitBreakerConfig { + min_apy_threshold_bps, + max_gas_cost, + cooldown_seconds, + auto_withdraw, + }; + env.storage().instance().set(&BreakerDataKey::GlobalConfig, &config); + } + + pub fn get_global_config(env: Env) -> CircuitBreakerConfig { + env.storage().instance() + .get(&BreakerDataKey::GlobalConfig) + .expect("Global config not set") + } + + pub fn get_protocol_config(env: Env, protocol: Address, asset: Address) -> YieldProtocolConfig { + env.storage().instance() + .get(&BreakerDataKey::ProtocolConfig(protocol, asset)) + .expect("Protocol not registered") + } + + pub fn set_pause(env: Env, admin: Address, paused: bool) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&BreakerDataKey::IsPaused, &paused); + } + + fn trigger_emergency_withdraw(env: &Env, protocol: &Address, asset: &Address, now: u64) { + let yield_adapter: Address = env.storage().instance() + .get(&BreakerDataKey::YieldAdapter) + .expect("Yield adapter not set"); + let vault_contract: Address = env.storage().instance() + .get(&BreakerDataKey::VaultContract) + .expect("Vault contract not set"); + + let args = Vec::from_array(env, [vault_contract.into_val(env), asset.clone().into_val(env)]); + env.invoke_contract::<()>(&yield_adapter, &Symbol::new(env, "emergency_withdraw_from_yield"), args); + + let mut config: YieldProtocolConfig = env.storage().instance() + .get(&BreakerDataKey::ProtocolConfig(protocol.clone(), asset.clone())) + .expect("Protocol not registered"); + config.circuit_breaker_active = true; + env.storage().instance().set(&BreakerDataKey::ProtocolConfig(protocol.clone(), asset.clone()), &config); + + EmergencyWithdrawalTriggered { + protocol: protocol.clone(), + asset: asset.clone(), + vault_id: 0, + amount: 0, + triggered_at: now, + }.publish(env); + } + + fn require_admin(env: &Env, admin: &Address) { + let stored: Address = env.storage().instance().get(&BreakerDataKey::Admin).expect("Admin not set"); + if stored != *admin { + admin.require_auth(); + } + } +}