From 041858a00d4ba07b0427f7269f7e5857b499dc45 Mon Sep 17 00:00:00 2001 From: Tu Pham Date: Mon, 25 May 2026 19:13:29 +0700 Subject: [PATCH] Add MEV protection for liquidations --- stellar-lend/contracts/hello-world/README.md | 1 + .../contracts/hello-world/src/errors.rs | 9 + stellar-lend/contracts/hello-world/src/lib.rs | 253 +++++- .../hello-world/src/mev_protection.rs | 823 ++++++++++++++++++ .../src/tests/mev_protection_test.rs | 295 ++++++- stellar-lend/contracts/lending/src/borrow.rs | 70 +- .../contracts/lending/src/interest_rate.rs | 4 +- stellar-lend/contracts/lending/src/lib.rs | 120 ++- .../contracts/lending/src/math_safety_test.rs | 11 + .../contracts/lending/src/mev_protection.rs | 201 +++++ 10 files changed, 1747 insertions(+), 40 deletions(-) create mode 100644 stellar-lend/contracts/lending/src/mev_protection.rs diff --git a/stellar-lend/contracts/hello-world/README.md b/stellar-lend/contracts/hello-world/README.md index 337247fa..98906f1a 100644 --- a/stellar-lend/contracts/hello-world/README.md +++ b/stellar-lend/contracts/hello-world/README.md @@ -9,6 +9,7 @@ This contract exposes the current public API for the main StellarLend protocol c - Core lending: `deposit_collateral`, `borrow_asset`, `repay_debt`, `withdraw_collateral`, `liquidate` - Risk controls: `set_risk_params`, `set_emergency_pause`, `can_be_liquidated`, `get_max_liquidatable_amount`, `get_liquidation_incentive_amount`, `require_min_collateral_ratio` - Flash loans: `execute_flash_loan`, `repay_flash_loan`, `configure_flash_loan` +- MEV protection: `commit_*_protected`, `commit_*_guarded`, `open_liq_batch_auction`, `submit_liq_batch_bid`, `settle_liq_batch_auction`, `register_private_mev_route`, `record_mev_gas_bid`, `get_mev_dashboard` - Treasury: `set_treasury`, `get_treasury`, `get_reserve_balance`, `claim_reserves`, `set_fee_config`, `get_fee_config` - Analytics and queries: `get_protocol_stats`, `get_protocol_report`, `get_user_position`, `get_user_report`, `get_recent_activity`, `get_user_asset_collateral`, `get_user_asset_list`, `get_user_total_collateral_value`, `get_health_factor` - Asset configuration: `update_asset_config` diff --git a/stellar-lend/contracts/hello-world/src/errors.rs b/stellar-lend/contracts/hello-world/src/errors.rs index 615f7131..7993d54c 100644 --- a/stellar-lend/contracts/hello-world/src/errors.rs +++ b/stellar-lend/contracts/hello-world/src/errors.rs @@ -277,6 +277,15 @@ impl_from_error!(MevProtectionError, { MevProtectionError::FeeCapExceeded => LendingError::FeeCapExceeded, MevProtectionError::InvalidAmount => LendingError::InvalidAmount, MevProtectionError::InvalidOperation => LendingError::InvalidState, + MevProtectionError::SlippageExpired => LendingError::CommitExpired, + MevProtectionError::SlippageExceeded => LendingError::LimitExceeded, + MevProtectionError::AuctionNotFound => LendingError::NotFound, + MevProtectionError::AuctionNotOpen => LendingError::InvalidState, + MevProtectionError::AuctionNotReady => LendingError::CommitNotReady, + MevProtectionError::BidNotFound => LendingError::NotFound, + MevProtectionError::BidTooLow => LendingError::LimitExceeded, + MevProtectionError::PrivateRouteRequired => LendingError::CommitRequired, + MevProtectionError::PrivateRouteNotFound => LendingError::NotFound, }); impl_from_error!(RepayError, { diff --git a/stellar-lend/contracts/hello-world/src/lib.rs b/stellar-lend/contracts/hello-world/src/lib.rs index b8dba465..82b9e0bb 100644 --- a/stellar-lend/contracts/hello-world/src/lib.rs +++ b/stellar-lend/contracts/hello-world/src/lib.rs @@ -1,7 +1,7 @@ #![allow(clippy::too_many_arguments)] #![allow(deprecated)] -use soroban_sdk::{contract, contractimpl, Address, Env, IntoVal, String, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Env, IntoVal, String, Symbol, Vec}; pub mod admin; pub mod analytics; @@ -781,6 +781,257 @@ impl HelloContract { mev_protection::get_ordering_stats(&env) } + pub fn requires_mev_commit(env: Env, amount: i128) -> bool { + mev_protection::requires_commit_reveal(&env, amount) + } + + pub fn commit_borrow_guarded( + env: Env, + user: Address, + asset: Option
, + amount: i128, + max_fee_bps: i128, + hint: mev_protection::TxOrderingHint, + guard: mev_protection::ExecutionGuard, + private_route: Option, + ) -> Result { + mev_protection::create_guarded_commit( + &env, + user, + mev_protection::SensitiveOperation::Borrow, + asset, + None, + None, + amount, + max_fee_bps, + hint, + guard, + private_route, + None, + ) + .map_err(Into::into) + } + + pub fn commit_withdraw_guarded( + env: Env, + user: Address, + asset: Option
, + amount: i128, + max_fee_bps: i128, + hint: mev_protection::TxOrderingHint, + guard: mev_protection::ExecutionGuard, + private_route: Option, + ) -> Result { + mev_protection::create_guarded_commit( + &env, + user, + mev_protection::SensitiveOperation::Withdraw, + asset, + None, + None, + amount, + max_fee_bps, + hint, + guard, + private_route, + None, + ) + .map_err(Into::into) + } + + pub fn commit_liquidation_guarded( + env: Env, + liquidator: Address, + borrower: Address, + debt_asset: Option
, + collateral_asset: Option
, + debt_amount: i128, + max_fee_bps: i128, + hint: mev_protection::TxOrderingHint, + guard: mev_protection::ExecutionGuard, + private_route: Option, + ) -> Result { + mev_protection::create_guarded_commit( + &env, + liquidator, + mev_protection::SensitiveOperation::Liquidate, + debt_asset, + collateral_asset, + Some(borrower), + debt_amount, + max_fee_bps, + hint, + guard, + private_route, + None, + ) + .map_err(Into::into) + } + + pub fn reveal_liquidation_guarded( + env: Env, + liquidator: Address, + commit_id: u64, + expected_collateral_out: i128, + ) -> Result<(i128, i128, i128), LendingError> { + let (borrower, debt_asset, collateral_asset, debt_amount) = + mev_protection::reveal_liquidation_with_output( + &env, + liquidator.clone(), + commit_id, + expected_collateral_out, + ) + .map_err(LendingError::from)?; + let result = liquidate::liquidate( + &env, + liquidator, + borrower, + debt_asset, + collateral_asset, + debt_amount, + ) + .map_err(LendingError::from)?; + if result.1 < expected_collateral_out { + return Err(LendingError::LimitExceeded); + } + Ok(result) + } + + pub fn register_private_mev_route( + env: Env, + caller: Address, + route_id: Symbol, + relay: Address, + ttl_secs: u64, + ) -> Result { + mev_protection::register_private_route(&env, caller, route_id, relay, ttl_secs) + .map_err(Into::into) + } + + pub fn record_private_mev_exec( + env: Env, + relay: Address, + commit_id: u64, + route_id: Symbol, + ) -> Result { + mev_protection::record_private_execution(&env, relay, commit_id, route_id) + .map_err(Into::into) + } + + pub fn open_liq_batch_auction( + env: Env, + opener: Address, + borrower: Address, + debt_asset: Option
, + collateral_asset: Option
, + debt_amount: i128, + min_rebate_bps: i128, + bidding_period_secs: u64, + ) -> Result { + mev_protection::open_liquidation_auction( + &env, + opener, + borrower, + debt_asset, + collateral_asset, + debt_amount, + min_rebate_bps, + bidding_period_secs, + ) + .map_err(Into::into) + } + + pub fn submit_liq_batch_bid( + env: Env, + liquidator: Address, + auction_id: u64, + repay_amount: i128, + rebate_bps: i128, + max_fee_bps: i128, + min_collateral_out: i128, + private_route: Option, + ) -> Result { + mev_protection::submit_liquidation_bid( + &env, + liquidator, + auction_id, + repay_amount, + rebate_bps, + max_fee_bps, + min_collateral_out, + private_route, + ) + .map_err(Into::into) + } + + pub fn settle_liq_batch_auction( + env: Env, + caller: Address, + auction_id: u64, + ) -> Result { + mev_protection::settle_liquidation_auction(&env, caller, auction_id) + .map_err(Into::into) + } + + pub fn commit_liq_batch_winner( + env: Env, + liquidator: Address, + auction_id: u64, + guard: mev_protection::ExecutionGuard, + private_route: Option, + ) -> Result { + mev_protection::create_liquidation_auction_commit( + &env, + liquidator, + auction_id, + guard, + private_route, + ) + .map_err(Into::into) + } + + pub fn get_liq_batch_auction( + env: Env, + auction_id: u64, + ) -> Option { + mev_protection::get_liquidation_auction(&env, auction_id) + } + + pub fn get_liq_batch_bid( + env: Env, + bid_id: u64, + ) -> Option { + mev_protection::get_liquidation_bid(&env, bid_id) + } + + pub fn record_mev_gas_bid( + env: Env, + reporter: Address, + operation: mev_protection::SensitiveOperation, + asset: Option
, + bid_microlumens: i128, + inclusion_delay_ledgers: u64, + ) -> Result { + mev_protection::record_gas_bid_sample( + &env, + reporter, + operation, + asset, + bid_microlumens, + inclusion_delay_ledgers, + ) + .map_err(Into::into) + } + + pub fn get_mev_dashboard( + env: Env, + operation: mev_protection::SensitiveOperation, + asset: Option
, + amount: i128, + ) -> mev_protection::MevMonitoringDashboard { + mev_protection::get_monitoring_dashboard(&env, operation, asset, amount) + } + /// Meta-tx style liquidation: liquidator authorizes intent off-chain. pub fn liquidate_intent( env: Env, diff --git a/stellar-lend/contracts/hello-world/src/mev_protection.rs b/stellar-lend/contracts/hello-world/src/mev_protection.rs index 2ff99c7b..1118871f 100644 --- a/stellar-lend/contracts/hello-world/src/mev_protection.rs +++ b/stellar-lend/contracts/hello-world/src/mev_protection.rs @@ -12,6 +12,15 @@ pub enum MevProtectionError { FeeCapExceeded = 6, InvalidAmount = 7, InvalidOperation = 8, + SlippageExpired = 9, + SlippageExceeded = 10, + AuctionNotFound = 11, + AuctionNotOpen = 12, + AuctionNotReady = 13, + BidNotFound = 14, + BidTooLow = 15, + PrivateRouteRequired = 16, + PrivateRouteNotFound = 17, } #[contracttype] @@ -41,10 +50,23 @@ pub struct MevProtectionConfig { pub base_protection_fee_bps: i128, pub surge_protection_fee_bps: i128, pub sandwich_threshold_bps: i128, + pub large_tx_threshold: i128, + pub default_auction_secs: u64, + pub min_auction_bid_rebate_bps: i128, + pub max_private_route_ttl_secs: u64, pub private_mempool_enabled: bool, pub batching_enabled: bool, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionGuard { + pub quoted_output_amount: i128, + pub min_output_amount: i128, + pub max_slippage_bps: i128, + pub deadline: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PendingCommit { @@ -61,6 +83,9 @@ pub struct PendingCommit { pub reveal_after: u64, pub expires_at: u64, pub commit_ledger: u32, + pub guard: Option, + pub private_route: Option, + pub auction_id: Option, } #[contracttype] @@ -80,12 +105,123 @@ pub struct OrderingStats { pub last_effective_fee_bps: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuctionStatus { + Open, + Settled, + Cancelled, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidationAuction { + pub id: u64, + pub opener: Address, + pub borrower: Address, + pub debt_asset: Option
, + pub collateral_asset: Option
, + pub debt_amount: i128, + pub min_rebate_bps: i128, + pub opened_at: u64, + pub bidding_deadline: u64, + pub status: AuctionStatus, + pub best_bid_id: Option, + pub winning_liquidator: Option
, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidationAuctionBid { + pub id: u64, + pub auction_id: u64, + pub liquidator: Address, + pub repay_amount: i128, + pub rebate_bps: i128, + pub max_fee_bps: i128, + pub min_collateral_out: i128, + pub private_route: Option, + pub submitted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuctionStats { + pub opened: u64, + pub bids: u64, + pub settled: u64, + pub executed: u64, + pub last_auction_id: u64, + pub last_settled_timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrivateMempoolRoute { + pub route_id: Symbol, + pub relay: Address, + pub registered_by: Address, + pub registered_at: u64, + pub expires_at: u64, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrivateExecutionReceipt { + pub commit_id: u64, + pub route_id: Symbol, + pub relay: Address, + pub received_at: u64, + pub expires_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrivateRouteStats { + pub active_routes: u32, + pub executions: u64, + pub last_execution_timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GasBidStats { + pub samples: u64, + pub min_bid_microlumens: i128, + pub max_bid_microlumens: i128, + pub avg_bid_microlumens: i128, + pub last_bid_microlumens: i128, + pub avg_inclusion_delay_ledgers: u64, + pub last_updated: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MevMonitoringDashboard { + pub ordering: OrderingStats, + pub gas_bids: GasBidStats, + pub auctions: AuctionStats, + pub private_routes: PrivateRouteStats, + pub recommended_fee_bps: i128, + pub recommended_hint: TxOrderingHint, +} + #[contracttype] #[derive(Clone)] enum MevDataKey { Config, NextCommitId, Commit(u64), + NextAuctionId, + NextBidId, + Auction(u64), + AuctionBid(u64), + AuctionStats, + PrivateRoute(Symbol), + PrivateReceipt(u64), + PrivateStats, + GasBidStats(Symbol, Option
), OrderingStats, LatestObservation(Symbol, Option
), PreviousObservation(Symbol, Option
), @@ -103,6 +239,10 @@ pub fn default_config() -> MevProtectionConfig { base_protection_fee_bps: 10, surge_protection_fee_bps: 60, sandwich_threshold_bps: 500, + large_tx_threshold: 100_000, + default_auction_secs: 60, + min_auction_bid_rebate_bps: 25, + max_private_route_ttl_secs: 600, private_mempool_enabled: true, batching_enabled: true, } @@ -137,6 +277,66 @@ pub fn create_commit( amount: i128, max_fee_bps: i128, hint: TxOrderingHint, +) -> Result { + create_commit_with_context( + env, + owner, + operation, + asset, + secondary_asset, + borrower, + amount, + max_fee_bps, + hint, + None, + None, + None, + ) +} + +pub fn create_guarded_commit( + env: &Env, + owner: Address, + operation: SensitiveOperation, + asset: Option
, + secondary_asset: Option
, + borrower: Option
, + amount: i128, + max_fee_bps: i128, + hint: TxOrderingHint, + guard: ExecutionGuard, + private_route: Option, + auction_id: Option, +) -> Result { + create_commit_with_context( + env, + owner, + operation, + asset, + secondary_asset, + borrower, + amount, + max_fee_bps, + hint, + Some(guard), + private_route, + auction_id, + ) +} + +fn create_commit_with_context( + env: &Env, + owner: Address, + operation: SensitiveOperation, + asset: Option
, + secondary_asset: Option
, + borrower: Option
, + amount: i128, + max_fee_bps: i128, + hint: TxOrderingHint, + guard: Option, + private_route: Option, + auction_id: Option, ) -> Result { owner.require_auth(); if amount <= 0 { @@ -147,6 +347,16 @@ pub fn create_commit( } let cfg = get_config(env); + if let Some(ref execution_guard) = guard { + validate_guard_config(env, execution_guard)?; + } + if let Some(ref route_id) = private_route { + ensure_private_route(env, route_id)?; + } + if let Some(id) = auction_id { + ensure_auction_for_commit(env, id, &owner)?; + } + let id = next_commit_id(env); let now = env.ledger().timestamp(); let commit = PendingCommit { @@ -163,6 +373,9 @@ pub fn create_commit( reveal_after: now.saturating_add(cfg.commit_delay_secs), expires_at: now.saturating_add(cfg.commit_expiry_secs), commit_ledger: env.ledger().sequence(), + guard, + private_route, + auction_id, }; env.storage() .persistent() @@ -240,6 +453,10 @@ pub fn execution_hint(env: &Env, requested: TxOrderingHint) -> TxOrderingHint { } } +pub fn requires_commit_reveal(env: &Env, amount: i128) -> bool { + amount >= get_config(env).large_tx_threshold +} + pub fn user_guidance(env: &Env, operation: SensitiveOperation) -> String { match (operation, execution_hint(env, TxOrderingHint::Default)) { (SensitiveOperation::Borrow, TxOrderingHint::PrivateMempool) => String::from_str( @@ -277,6 +494,409 @@ pub fn get_ordering_stats(env: &Env) -> OrderingStats { }) } +pub fn get_auction_stats(env: &Env) -> AuctionStats { + env.storage() + .persistent() + .get(&MevDataKey::AuctionStats) + .unwrap_or(AuctionStats { + opened: 0, + bids: 0, + settled: 0, + executed: 0, + last_auction_id: 0, + last_settled_timestamp: 0, + }) +} + +pub fn get_private_route_stats(env: &Env) -> PrivateRouteStats { + env.storage() + .persistent() + .get(&MevDataKey::PrivateStats) + .unwrap_or(PrivateRouteStats { + active_routes: 0, + executions: 0, + last_execution_timestamp: 0, + }) +} + +pub fn get_gas_bid_stats( + env: &Env, + operation: SensitiveOperation, + asset: Option
, +) -> GasBidStats { + let key = MevDataKey::GasBidStats(operation_symbol(env, &operation), asset); + env.storage().persistent().get(&key).unwrap_or(GasBidStats { + samples: 0, + min_bid_microlumens: 0, + max_bid_microlumens: 0, + avg_bid_microlumens: 0, + last_bid_microlumens: 0, + avg_inclusion_delay_ledgers: 0, + last_updated: 0, + }) +} + +pub fn get_monitoring_dashboard( + env: &Env, + operation: SensitiveOperation, + asset: Option
, + amount: i128, +) -> MevMonitoringDashboard { + let recommended_hint = execution_hint(env, TxOrderingHint::Default); + let recommended_fee_bps = preview_fee_bps(env, operation.clone(), asset.clone(), amount); + MevMonitoringDashboard { + ordering: get_ordering_stats(env), + gas_bids: get_gas_bid_stats(env, operation, asset), + auctions: get_auction_stats(env), + private_routes: get_private_route_stats(env), + recommended_fee_bps, + recommended_hint, + } +} + +pub fn record_gas_bid_sample( + env: &Env, + reporter: Address, + operation: SensitiveOperation, + asset: Option
, + bid_microlumens: i128, + inclusion_delay_ledgers: u64, +) -> Result { + reporter.require_auth(); + if bid_microlumens <= 0 { + return Err(MevProtectionError::InvalidAmount); + } + + let key = MevDataKey::GasBidStats(operation_symbol(env, &operation), asset); + let mut stats = env.storage().persistent().get(&key).unwrap_or(GasBidStats { + samples: 0, + min_bid_microlumens: bid_microlumens, + max_bid_microlumens: bid_microlumens, + avg_bid_microlumens: 0, + last_bid_microlumens: 0, + avg_inclusion_delay_ledgers: 0, + last_updated: 0, + }); + + let next_samples = stats.samples.saturating_add(1); + stats.min_bid_microlumens = if stats.samples == 0 { + bid_microlumens + } else { + stats.min_bid_microlumens.min(bid_microlumens) + }; + stats.max_bid_microlumens = stats.max_bid_microlumens.max(bid_microlumens); + stats.avg_bid_microlumens = stats + .avg_bid_microlumens + .saturating_mul(i128::from(stats.samples)) + .saturating_add(bid_microlumens) + .saturating_div(i128::from(next_samples)); + stats.avg_inclusion_delay_ledgers = stats + .avg_inclusion_delay_ledgers + .saturating_mul(stats.samples) + .saturating_add(inclusion_delay_ledgers) + .saturating_div(next_samples); + stats.samples = next_samples; + stats.last_bid_microlumens = bid_microlumens; + stats.last_updated = env.ledger().timestamp(); + + env.storage().persistent().set(&key, &stats); + Ok(stats) +} + +pub fn register_private_route( + env: &Env, + caller: Address, + route_id: Symbol, + relay: Address, + ttl_secs: u64, +) -> Result { + caller.require_auth(); + let cfg = get_config(env); + if !cfg.private_mempool_enabled || ttl_secs == 0 || ttl_secs > cfg.max_private_route_ttl_secs { + return Err(MevProtectionError::InvalidConfig); + } + + let now = env.ledger().timestamp(); + let existed: Option = env + .storage() + .persistent() + .get(&MevDataKey::PrivateRoute(route_id.clone())); + let route = PrivateMempoolRoute { + route_id: route_id.clone(), + relay, + registered_by: caller, + registered_at: now, + expires_at: now.saturating_add(ttl_secs), + active: true, + }; + env.storage() + .persistent() + .set(&MevDataKey::PrivateRoute(route_id), &route); + + if existed.is_none() { + let mut stats = get_private_route_stats(env); + stats.active_routes = stats.active_routes.saturating_add(1); + env.storage() + .persistent() + .set(&MevDataKey::PrivateStats, &stats); + } + Ok(route) +} + +pub fn get_private_route(env: &Env, route_id: Symbol) -> Option { + env.storage() + .persistent() + .get(&MevDataKey::PrivateRoute(route_id)) +} + +pub fn record_private_execution( + env: &Env, + relay: Address, + commit_id: u64, + route_id: Symbol, +) -> Result { + relay.require_auth(); + let commit = load_commit(env, commit_id)?; + let route = ensure_private_route(env, &route_id)?; + if route.relay != relay { + return Err(MevProtectionError::Unauthorized); + } + if commit.hint != TxOrderingHint::PrivateMempool { + return Err(MevProtectionError::InvalidOperation); + } + if let Some(ref expected_route) = commit.private_route { + if expected_route != &route_id { + return Err(MevProtectionError::PrivateRouteRequired); + } + } + + let now = env.ledger().timestamp(); + let receipt = PrivateExecutionReceipt { + commit_id, + route_id: route.route_id, + relay, + received_at: now, + expires_at: commit.expires_at, + }; + env.storage() + .persistent() + .set(&MevDataKey::PrivateReceipt(commit_id), &receipt); + + let mut stats = get_private_route_stats(env); + stats.executions = stats.executions.saturating_add(1); + stats.last_execution_timestamp = now; + env.storage() + .persistent() + .set(&MevDataKey::PrivateStats, &stats); + Ok(receipt) +} + +pub fn open_liquidation_auction( + env: &Env, + opener: Address, + borrower: Address, + debt_asset: Option
, + collateral_asset: Option
, + debt_amount: i128, + min_rebate_bps: i128, + bidding_period_secs: u64, +) -> Result { + opener.require_auth(); + let cfg = get_config(env); + if !cfg.batching_enabled { + return Err(MevProtectionError::InvalidConfig); + } + if debt_amount <= 0 { + return Err(MevProtectionError::InvalidAmount); + } + if !(0..=MAX_BPS).contains(&min_rebate_bps) || min_rebate_bps < cfg.min_auction_bid_rebate_bps { + return Err(MevProtectionError::InvalidConfig); + } + + let now = env.ledger().timestamp(); + let auction_id = next_auction_id(env); + let auction_secs = if bidding_period_secs == 0 { + cfg.default_auction_secs + } else { + bidding_period_secs + }; + let auction = LiquidationAuction { + id: auction_id, + opener, + borrower, + debt_asset, + collateral_asset, + debt_amount, + min_rebate_bps, + opened_at: now, + bidding_deadline: now.saturating_add(auction_secs), + status: AuctionStatus::Open, + best_bid_id: None, + winning_liquidator: None, + }; + env.storage() + .persistent() + .set(&MevDataKey::Auction(auction_id), &auction); + + let mut stats = get_auction_stats(env); + stats.opened = stats.opened.saturating_add(1); + stats.last_auction_id = auction_id; + env.storage() + .persistent() + .set(&MevDataKey::AuctionStats, &stats); + Ok(auction_id) +} + +pub fn submit_liquidation_bid( + env: &Env, + liquidator: Address, + auction_id: u64, + repay_amount: i128, + rebate_bps: i128, + max_fee_bps: i128, + min_collateral_out: i128, + private_route: Option, +) -> Result { + liquidator.require_auth(); + if repay_amount <= 0 || min_collateral_out < 0 { + return Err(MevProtectionError::InvalidAmount); + } + if !(0..=MAX_BPS).contains(&rebate_bps) || !(0..=MAX_BPS).contains(&max_fee_bps) { + return Err(MevProtectionError::InvalidConfig); + } + if let Some(ref route_id) = private_route { + ensure_private_route(env, route_id)?; + } + + let mut auction = load_auction(env, auction_id)?; + let now = env.ledger().timestamp(); + if auction.status != AuctionStatus::Open || now > auction.bidding_deadline { + return Err(MevProtectionError::AuctionNotOpen); + } + if rebate_bps < auction.min_rebate_bps { + return Err(MevProtectionError::BidTooLow); + } + + let bid_id = next_bid_id(env); + let bid = LiquidationAuctionBid { + id: bid_id, + auction_id, + liquidator, + repay_amount, + rebate_bps, + max_fee_bps, + min_collateral_out, + private_route, + submitted_at: now, + }; + + let replaces_best = match auction.best_bid_id { + None => true, + Some(best_id) => { + let best = load_bid(env, best_id)?; + bid.rebate_bps > best.rebate_bps + || (bid.rebate_bps == best.rebate_bps && bid.repay_amount > best.repay_amount) + } + }; + if replaces_best { + auction.best_bid_id = Some(bid_id); + auction.winning_liquidator = Some(bid.liquidator.clone()); + env.storage() + .persistent() + .set(&MevDataKey::Auction(auction_id), &auction); + } + + env.storage() + .persistent() + .set(&MevDataKey::AuctionBid(bid_id), &bid); + let mut stats = get_auction_stats(env); + stats.bids = stats.bids.saturating_add(1); + env.storage() + .persistent() + .set(&MevDataKey::AuctionStats, &stats); + Ok(bid_id) +} + +pub fn settle_liquidation_auction( + env: &Env, + caller: Address, + auction_id: u64, +) -> Result { + caller.require_auth(); + let mut auction = load_auction(env, auction_id)?; + let now = env.ledger().timestamp(); + if auction.status != AuctionStatus::Open { + return Err(MevProtectionError::AuctionNotOpen); + } + if now <= auction.bidding_deadline { + return Err(MevProtectionError::AuctionNotReady); + } + let best_id = auction.best_bid_id.ok_or(MevProtectionError::BidNotFound)?; + let best = load_bid(env, best_id)?; + + auction.status = AuctionStatus::Settled; + auction.winning_liquidator = Some(best.liquidator.clone()); + env.storage() + .persistent() + .set(&MevDataKey::Auction(auction_id), &auction); + + let mut stats = get_auction_stats(env); + stats.settled = stats.settled.saturating_add(1); + stats.last_settled_timestamp = now; + env.storage() + .persistent() + .set(&MevDataKey::AuctionStats, &stats); + Ok(best) +} + +pub fn get_liquidation_auction(env: &Env, auction_id: u64) -> Option { + env.storage() + .persistent() + .get(&MevDataKey::Auction(auction_id)) +} + +pub fn get_liquidation_bid(env: &Env, bid_id: u64) -> Option { + env.storage() + .persistent() + .get(&MevDataKey::AuctionBid(bid_id)) +} + +pub fn create_liquidation_auction_commit( + env: &Env, + liquidator: Address, + auction_id: u64, + guard: ExecutionGuard, + private_route: Option, +) -> Result { + let auction = load_auction(env, auction_id)?; + if auction.status != AuctionStatus::Settled { + return Err(MevProtectionError::AuctionNotReady); + } + let best_id = auction.best_bid_id.ok_or(MevProtectionError::BidNotFound)?; + let best = load_bid(env, best_id)?; + if best.liquidator != liquidator { + return Err(MevProtectionError::Unauthorized); + } + if guard.min_output_amount < best.min_collateral_out { + return Err(MevProtectionError::SlippageExceeded); + } + + create_commit_with_context( + env, + liquidator, + SensitiveOperation::Liquidate, + auction.debt_asset, + auction.collateral_asset, + Some(auction.borrower), + auction.debt_amount, + best.max_fee_bps, + TxOrderingHint::BatchAuction, + Some(guard), + private_route, + Some(auction_id), + ) +} + pub fn reveal_borrow( env: &Env, owner: Address, @@ -284,6 +904,8 @@ pub fn reveal_borrow( ) -> Result<(Option
, i128, i128), MevProtectionError> { owner.require_auth(); let commit = validate_reveal(env, &owner, commit_id, SensitiveOperation::Borrow)?; + validate_guard_if_present(env, &commit, commit.amount)?; + validate_private_receipt_if_needed(env, &commit)?; let effective_fee_bps = preview_fee_bps( env, SensitiveOperation::Borrow, @@ -314,6 +936,8 @@ pub fn reveal_withdraw( ) -> Result<(Option
, i128), MevProtectionError> { owner.require_auth(); let commit = validate_reveal(env, &owner, commit_id, SensitiveOperation::Withdraw)?; + validate_guard_if_present(env, &commit, commit.amount)?; + validate_private_receipt_if_needed(env, &commit)?; let effective_fee_bps = preview_fee_bps( env, SensitiveOperation::Withdraw, @@ -344,6 +968,11 @@ pub fn reveal_liquidation( ) -> Result<(Address, Option
, Option
, i128), MevProtectionError> { owner.require_auth(); let commit = validate_reveal(env, &owner, commit_id, SensitiveOperation::Liquidate)?; + if commit.guard.is_some() { + return Err(MevProtectionError::InvalidOperation); + } + validate_private_receipt_if_needed(env, &commit)?; + validate_auction_commit_if_needed(env, &commit)?; let effective_fee_bps = preview_fee_bps( env, SensitiveOperation::Liquidate, @@ -355,6 +984,7 @@ pub fn reveal_liquidation( } let borrower = commit .borrower + .clone() .ok_or(MevProtectionError::InvalidOperation)?; record_ordering_signal( env, @@ -367,6 +997,52 @@ pub fn reveal_liquidation( env.storage() .persistent() .remove(&MevDataKey::Commit(commit_id)); + record_auction_execution_if_needed(env, &commit); + Ok(( + borrower, + commit.asset, + commit.secondary_asset, + commit.amount, + )) +} + +pub fn reveal_liquidation_with_output( + env: &Env, + owner: Address, + commit_id: u64, + expected_collateral_out: i128, +) -> Result<(Address, Option
, Option
, i128), MevProtectionError> { + owner.require_auth(); + let commit = validate_reveal(env, &owner, commit_id, SensitiveOperation::Liquidate)?; + validate_guard_if_present(env, &commit, expected_collateral_out)?; + validate_private_receipt_if_needed(env, &commit)?; + validate_auction_commit_if_needed(env, &commit)?; + + let effective_fee_bps = preview_fee_bps( + env, + SensitiveOperation::Liquidate, + commit.asset.clone(), + commit.amount, + ); + if effective_fee_bps > commit.max_fee_bps { + return Err(MevProtectionError::FeeCapExceeded); + } + let borrower = commit + .borrower + .clone() + .ok_or(MevProtectionError::InvalidOperation)?; + record_ordering_signal( + env, + owner, + SensitiveOperation::Liquidate, + commit.asset.clone(), + commit.amount, + effective_fee_bps, + ); + env.storage() + .persistent() + .remove(&MevDataKey::Commit(commit_id)); + record_auction_execution_if_needed(env, &commit); Ok(( borrower, commit.asset, @@ -475,6 +1151,149 @@ fn next_commit_id(env: &Env) -> u64 { id } +fn next_auction_id(env: &Env) -> u64 { + let id = env + .storage() + .persistent() + .get::(&MevDataKey::NextAuctionId) + .unwrap_or(1); + env.storage() + .persistent() + .set(&MevDataKey::NextAuctionId, &id.saturating_add(1)); + id +} + +fn next_bid_id(env: &Env) -> u64 { + let id = env + .storage() + .persistent() + .get::(&MevDataKey::NextBidId) + .unwrap_or(1); + env.storage() + .persistent() + .set(&MevDataKey::NextBidId, &id.saturating_add(1)); + id +} + +fn load_auction(env: &Env, auction_id: u64) -> Result { + env.storage() + .persistent() + .get(&MevDataKey::Auction(auction_id)) + .ok_or(MevProtectionError::AuctionNotFound) +} + +fn load_bid(env: &Env, bid_id: u64) -> Result { + env.storage() + .persistent() + .get(&MevDataKey::AuctionBid(bid_id)) + .ok_or(MevProtectionError::BidNotFound) +} + +fn ensure_private_route( + env: &Env, + route_id: &Symbol, +) -> Result { + let route: PrivateMempoolRoute = env + .storage() + .persistent() + .get(&MevDataKey::PrivateRoute(route_id.clone())) + .ok_or(MevProtectionError::PrivateRouteNotFound)?; + if !route.active || env.ledger().timestamp() > route.expires_at { + return Err(MevProtectionError::PrivateRouteNotFound); + } + Ok(route) +} + +fn ensure_auction_for_commit( + env: &Env, + auction_id: u64, + liquidator: &Address, +) -> Result<(), MevProtectionError> { + let auction = load_auction(env, auction_id)?; + if auction.status != AuctionStatus::Settled { + return Err(MevProtectionError::AuctionNotReady); + } + if auction.winning_liquidator.as_ref() != Some(liquidator) { + return Err(MevProtectionError::Unauthorized); + } + Ok(()) +} + +fn validate_guard_config(env: &Env, guard: &ExecutionGuard) -> Result<(), MevProtectionError> { + if guard.quoted_output_amount <= 0 + || guard.min_output_amount < 0 + || guard.min_output_amount > guard.quoted_output_amount + || !(0..=MAX_BPS).contains(&guard.max_slippage_bps) + || guard.deadline <= env.ledger().timestamp() + { + return Err(MevProtectionError::InvalidConfig); + } + Ok(()) +} + +fn validate_guard_if_present( + env: &Env, + commit: &PendingCommit, + actual_output_amount: i128, +) -> Result<(), MevProtectionError> { + let Some(ref guard) = commit.guard else { + return Ok(()); + }; + if env.ledger().timestamp() > guard.deadline { + return Err(MevProtectionError::SlippageExpired); + } + + let slippage_floor = guard + .quoted_output_amount + .saturating_mul(MAX_BPS.saturating_sub(guard.max_slippage_bps)) + .saturating_div(MAX_BPS); + let required_output = guard.min_output_amount.max(slippage_floor); + if actual_output_amount < required_output { + return Err(MevProtectionError::SlippageExceeded); + } + Ok(()) +} + +fn validate_private_receipt_if_needed( + env: &Env, + commit: &PendingCommit, +) -> Result<(), MevProtectionError> { + let Some(ref route_id) = commit.private_route else { + return Ok(()); + }; + let receipt: PrivateExecutionReceipt = env + .storage() + .persistent() + .get(&MevDataKey::PrivateReceipt(commit.id)) + .ok_or(MevProtectionError::PrivateRouteRequired)?; + if &receipt.route_id != route_id || env.ledger().timestamp() > receipt.expires_at { + return Err(MevProtectionError::PrivateRouteRequired); + } + ensure_private_route(env, route_id)?; + Ok(()) +} + +fn validate_auction_commit_if_needed( + env: &Env, + commit: &PendingCommit, +) -> Result<(), MevProtectionError> { + let Some(auction_id) = commit.auction_id else { + return Ok(()); + }; + ensure_auction_for_commit(env, auction_id, &commit.owner) +} + +fn record_auction_execution_if_needed(env: &Env, commit: &PendingCommit) { + if commit.auction_id.is_none() { + return; + } + let mut stats = get_auction_stats(env); + stats.executed = stats.executed.saturating_add(1); + env.storage() + .persistent() + .set(&MevDataKey::AuctionStats, &stats); +} + fn validate_config(config: &MevProtectionConfig) -> Result<(), MevProtectionError> { if config.commit_delay_secs == 0 || config.commit_expiry_secs <= config.commit_delay_secs @@ -483,6 +1302,10 @@ fn validate_config(config: &MevProtectionConfig) -> Result<(), MevProtectionErro || !(0..=MAX_BPS).contains(&config.base_protection_fee_bps) || !(0..=MAX_BPS).contains(&config.surge_protection_fee_bps) || !(0..=MAX_BPS).contains(&config.sandwich_threshold_bps) + || config.large_tx_threshold <= 0 + || config.default_auction_secs == 0 + || !(0..=MAX_BPS).contains(&config.min_auction_bid_rebate_bps) + || config.max_private_route_ttl_secs == 0 { return Err(MevProtectionError::InvalidConfig); } diff --git a/stellar-lend/contracts/hello-world/src/tests/mev_protection_test.rs b/stellar-lend/contracts/hello-world/src/tests/mev_protection_test.rs index 291908c2..7efd3d69 100644 --- a/stellar-lend/contracts/hello-world/src/tests/mev_protection_test.rs +++ b/stellar-lend/contracts/hello-world/src/tests/mev_protection_test.rs @@ -1,8 +1,12 @@ -use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env, Symbol}; use crate::mev_protection::{ - create_commit, execution_hint, get_commit, get_ordering_stats, reveal_borrow, user_guidance, - MevProtectionError, SensitiveOperation, TxOrderingHint, + create_commit, create_guarded_commit, create_liquidation_auction_commit, execution_hint, + get_commit, get_monitoring_dashboard, get_ordering_stats, open_liquidation_auction, + record_gas_bid_sample, record_private_execution, register_private_route, reveal_borrow, + reveal_liquidation, reveal_liquidation_with_output, settle_liquidation_auction, + submit_liquidation_bid, user_guidance, AuctionStatus, ExecutionGuard, MevProtectionError, + SensitiveOperation, TxOrderingHint, }; use crate::HelloContract; @@ -224,3 +228,288 @@ fn test_guidance_hint_and_commit_lookup() { .unwrap(); assert_eq!(commit.owner, user); } + +#[test] +fn test_guarded_liquidation_enforces_deadline_and_slippage() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + let liquidator = Address::generate(&env); + let borrower = Address::generate(&env); + let asset = Address::generate(&env); + + let guard = ExecutionGuard { + quoted_output_amount: 1_100, + min_output_amount: 1_050, + max_slippage_bps: 500, + deadline: 90, + }; + let low_output_commit = env.as_contract(&contract_id, || { + create_guarded_commit( + &env, + liquidator.clone(), + SensitiveOperation::Liquidate, + Some(asset.clone()), + Some(asset.clone()), + Some(borrower.clone()), + 1_000, + 100, + TxOrderingHint::DelayedReveal, + guard.clone(), + None, + None, + ) + .unwrap() + }); + + env.ledger().with_mut(|li| li.timestamp = 31); + let err = env + .as_contract(&contract_id, || { + reveal_liquidation_with_output(&env, liquidator.clone(), low_output_commit, 1_000) + }) + .unwrap_err(); + assert_eq!(err, MevProtectionError::SlippageExceeded); + + let expired_commit = env.as_contract(&contract_id, || { + create_guarded_commit( + &env, + liquidator.clone(), + SensitiveOperation::Liquidate, + Some(asset.clone()), + Some(asset), + Some(borrower), + 1_000, + 100, + TxOrderingHint::DelayedReveal, + guard, + None, + None, + ) + .unwrap() + }); + env.ledger().with_mut(|li| li.timestamp = 91); + let err = env + .as_contract(&contract_id, || { + reveal_liquidation_with_output(&env, liquidator, expired_commit, 1_100) + }) + .unwrap_err(); + assert_eq!(err, MevProtectionError::SlippageExpired); +} + +#[test] +fn test_private_route_receipt_required_for_private_commit() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + let liquidator = Address::generate(&env); + let borrower = Address::generate(&env); + let relay = Address::generate(&env); + let asset = Address::generate(&env); + let route_id = Symbol::new(&env, "flashbots"); + + env.as_contract(&contract_id, || { + register_private_route(&env, relay.clone(), route_id.clone(), relay.clone(), 120).unwrap() + }); + let commit_id = env.as_contract(&contract_id, || { + create_guarded_commit( + &env, + liquidator.clone(), + SensitiveOperation::Liquidate, + Some(asset.clone()), + Some(asset), + Some(borrower), + 1_000, + 100, + TxOrderingHint::PrivateMempool, + ExecutionGuard { + quoted_output_amount: 1_100, + min_output_amount: 1_000, + max_slippage_bps: 1_000, + deadline: 200, + }, + Some(route_id.clone()), + None, + ) + .unwrap() + }); + + env.ledger().with_mut(|li| li.timestamp = 31); + let err = env + .as_contract(&contract_id, || { + reveal_liquidation_with_output(&env, liquidator.clone(), commit_id, 1_100) + }) + .unwrap_err(); + assert_eq!(err, MevProtectionError::PrivateRouteRequired); + + env.as_contract(&contract_id, || { + record_private_execution(&env, relay, commit_id, route_id).unwrap() + }); + let revealed = env + .as_contract(&contract_id, || { + reveal_liquidation_with_output(&env, liquidator, commit_id, 1_100) + }) + .unwrap(); + assert_eq!(revealed.3, 1_000); +} + +#[test] +fn test_liquidation_batch_auction_picks_best_bid_and_commit() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + let opener = Address::generate(&env); + let borrower = Address::generate(&env); + let bidder_a = Address::generate(&env); + let bidder_b = Address::generate(&env); + let asset = Address::generate(&env); + + let auction_id = env.as_contract(&contract_id, || { + open_liquidation_auction( + &env, + opener, + borrower, + Some(asset.clone()), + Some(asset), + 5_000, + 25, + 20, + ) + .unwrap() + }); + + env.as_contract(&contract_id, || { + submit_liquidation_bid(&env, bidder_a, auction_id, 4_500, 50, 100, 4_900, None).unwrap() + }); + let best_bid_id = env.as_contract(&contract_id, || { + submit_liquidation_bid( + &env, + bidder_b.clone(), + auction_id, + 5_000, + 75, + 100, + 5_200, + None, + ) + .unwrap() + }); + + env.ledger().with_mut(|li| li.timestamp = 21); + let best = env + .as_contract(&contract_id, || { + settle_liquidation_auction(&env, bidder_b.clone(), auction_id) + }) + .unwrap(); + assert_eq!(best.id, best_bid_id); + assert_eq!(best.liquidator, bidder_b); + + let commit_id = env.as_contract(&contract_id, || { + create_liquidation_auction_commit( + &env, + best.liquidator.clone(), + auction_id, + ExecutionGuard { + quoted_output_amount: 5_500, + min_output_amount: 5_200, + max_slippage_bps: 600, + deadline: 100, + }, + None, + ) + .unwrap() + }); + let auction = env + .as_contract(&contract_id, || get_commit(&env, commit_id)) + .unwrap(); + assert_eq!(auction.auction_id, Some(auction_id)); + assert_eq!(auction.hint, TxOrderingHint::BatchAuction); + + let stored = env + .as_contract(&contract_id, || { + crate::mev_protection::get_liquidation_auction(&env, auction_id) + }) + .unwrap(); + assert_eq!(stored.status, AuctionStatus::Settled); +} + +#[test] +fn test_gas_bid_dashboard_tracks_analysis() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + let reporter = Address::generate(&env); + let asset = Address::generate(&env); + + let stats = env + .as_contract(&contract_id, || { + record_gas_bid_sample( + &env, + reporter.clone(), + SensitiveOperation::Liquidate, + Some(asset.clone()), + 200, + 3, + ) + }) + .unwrap(); + assert_eq!(stats.samples, 1); + + env.as_contract(&contract_id, || { + record_gas_bid_sample( + &env, + reporter, + SensitiveOperation::Liquidate, + Some(asset.clone()), + 400, + 5, + ) + .unwrap() + }); + + let dashboard = env.as_contract(&contract_id, || { + get_monitoring_dashboard(&env, SensitiveOperation::Liquidate, Some(asset), 1_000) + }); + assert_eq!(dashboard.gas_bids.samples, 2); + assert_eq!(dashboard.gas_bids.avg_bid_microlumens, 300); + assert_eq!(dashboard.recommended_hint, TxOrderingHint::PrivateMempool); +} + +#[test] +fn test_guarded_liquidation_cannot_use_unguarded_reveal() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = setup_contract(&env); + let liquidator = Address::generate(&env); + let borrower = Address::generate(&env); + + let commit_id = env.as_contract(&contract_id, || { + create_guarded_commit( + &env, + liquidator.clone(), + SensitiveOperation::Liquidate, + None, + None, + Some(borrower), + 1_000, + 100, + TxOrderingHint::DelayedReveal, + ExecutionGuard { + quoted_output_amount: 1_000, + min_output_amount: 950, + max_slippage_bps: 500, + deadline: 100, + }, + None, + None, + ) + .unwrap() + }); + + env.ledger().with_mut(|li| li.timestamp = 31); + let err = env + .as_contract(&contract_id, || { + reveal_liquidation(&env, liquidator, commit_id) + }) + .unwrap_err(); + assert_eq!(err, MevProtectionError::InvalidOperation); +} diff --git a/stellar-lend/contracts/lending/src/borrow.rs b/stellar-lend/contracts/lending/src/borrow.rs index 7bf0b628..5d269d3c 100644 --- a/stellar-lend/contracts/lending/src/borrow.rs +++ b/stellar-lend/contracts/lending/src/borrow.rs @@ -198,7 +198,6 @@ pub fn set_variable_borrow_rate_bps( if *admin != current { return Err(BorrowError::Unauthorized); } - admin.require_auth(); if !(0..=10000).contains(&rate_bps) { return Err(BorrowError::InvalidAmount); } @@ -242,13 +241,22 @@ fn get_rate_switch_fee_bps(env: &Env) -> i128 { } fn get_stable_rate_state(env: &Env) -> StableRateState { - env.storage() + if let Some(state) = env + .storage() .persistent() .get(&BorrowDataKey::StableRateState) - .unwrap_or(StableRateState { - avg_rate_bps: get_current_variable_rate_bps(env).unwrap_or(0), - last_update: env.ledger().timestamp(), - }) + { + return state; + } + + let state = StableRateState { + avg_rate_bps: get_current_variable_rate_bps(env).unwrap_or(0), + last_update: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&BorrowDataKey::StableRateState, &state); + state } fn update_stable_rate_state_if_needed(env: &Env) -> StableRateState { @@ -747,25 +755,30 @@ fn get_debt_position( .persistent() .get::(&BorrowDataKey::BorrowUserDebt(user.clone())) { - return DebtPosition { - borrowed_amount: legacy.borrowed_amount, - interest_accrued: legacy.interest_accrued, - last_update: legacy.last_update, - asset: legacy.asset, - rate_type: RateType::Variable, - stable_rate_bps: 0, - }; + if legacy.rate_type == RateType::Variable { + return DebtPosition { + borrowed_amount: legacy.borrowed_amount, + interest_accrued: legacy.interest_accrued, + last_update: legacy.last_update, + asset: legacy.asset, + rate_type: RateType::Variable, + stable_rate_bps: 0, + }; + } } } - env.storage().persistent().get(&key).unwrap_or(DebtPosition { - borrowed_amount: 0, - interest_accrued: 0, - last_update: env.ledger().timestamp(), - asset: default_asset.cloned().unwrap_or_else(|| user.clone()), - rate_type, - stable_rate_bps: 0, - }) + env.storage() + .persistent() + .get(&key) + .unwrap_or(DebtPosition { + borrowed_amount: 0, + interest_accrued: 0, + last_update: env.ledger().timestamp(), + asset: default_asset.cloned().unwrap_or_else(|| user.clone()), + rate_type, + stable_rate_bps: 0, + }) } fn save_debt_position(env: &Env, user: &Address, position: &DebtPosition) { @@ -890,9 +903,10 @@ pub fn initialize_borrow_settings( .persistent() .has(&BorrowDataKey::StableRatePremiumBps) { - env.storage() - .persistent() - .set(&BorrowDataKey::StableRatePremiumBps, &DEFAULT_STABLE_PREMIUM_BPS); + env.storage().persistent().set( + &BorrowDataKey::StableRatePremiumBps, + &DEFAULT_STABLE_PREMIUM_BPS, + ); } if !env .storage() @@ -904,7 +918,11 @@ pub fn initialize_borrow_settings( &DEFAULT_STABLE_RECALC_INTERVAL_SECS, ); } - if !env.storage().persistent().has(&BorrowDataKey::RateSwitchFeeBps) { + if !env + .storage() + .persistent() + .has(&BorrowDataKey::RateSwitchFeeBps) + { env.storage() .persistent() .set(&BorrowDataKey::RateSwitchFeeBps, &DEFAULT_SWITCH_FEE_BPS); diff --git a/stellar-lend/contracts/lending/src/interest_rate.rs b/stellar-lend/contracts/lending/src/interest_rate.rs index 862865d0..9eae263a 100644 --- a/stellar-lend/contracts/lending/src/interest_rate.rs +++ b/stellar-lend/contracts/lending/src/interest_rate.rs @@ -1,6 +1,6 @@ use soroban_sdk::{contracterror, contracttype, Address, Env}; -use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowError, BorrowDataKey}; +use crate::borrow::{get_admin, get_debt_ceiling, get_total_debt, BorrowDataKey, BorrowError}; const BPS_SCALE: i128 = 10_000; @@ -41,7 +41,7 @@ pub struct InterestRateConfigUpdate { fn default_config(env: &Env) -> InterestRateConfig { InterestRateConfig { - base_rate_bps: 100, + base_rate_bps: 500, kink_utilization_bps: 8000, slope_bps: 2000, jump_slope_bps: 10_000, diff --git a/stellar-lend/contracts/lending/src/lib.rs b/stellar-lend/contracts/lending/src/lib.rs index c69ea29a..29a9690f 100644 --- a/stellar-lend/contracts/lending/src/lib.rs +++ b/stellar-lend/contracts/lending/src/lib.rs @@ -1,21 +1,27 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Val, Vec}; +use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Symbol, Val, Vec}; mod borrow; mod deposit; mod events; mod flash_loan; +mod interest_rate; +mod mev_protection; mod pause; +mod risk_monitor; mod token_receiver; mod withdraw; use borrow::{ - borrow as borrow_cmd, deposit as borrow_deposit, get_admin as get_borrow_admin, - get_user_collateral as get_borrow_collateral, get_user_debt as get_borrow_debt, + borrow as borrow_cmd, borrow_with_rate as borrow_with_rate_logic, deposit as borrow_deposit, + get_admin as get_borrow_admin, get_user_collateral as get_borrow_collateral, + get_user_debt as get_borrow_debt, get_user_debt_with_rate as get_borrow_debt_with_rate, initialize_borrow_settings as initialize_borrow_logic, repay as borrow_repay, set_admin as set_borrow_admin, set_liquidation_threshold_bps as set_liquidation_threshold_logic, - set_oracle as set_oracle_logic, BorrowCollateral, BorrowError, DebtPosition, + set_oracle as set_oracle_logic, set_variable_borrow_rate_bps as set_variable_borrow_rate_logic, + switch_rate_type as switch_rate_type_logic, BorrowCollateral, BorrowError, DebtPosition, + RateType, }; use deposit::{ deposit as deposit_logic, get_user_collateral as get_deposit_collateral, @@ -48,10 +54,9 @@ use insurance::{ collect_premium as insurance_collect_premium, evaluate_claim as insurance_evaluate_claim, fund_pool as insurance_fund_pool, get_analytics as insurance_get_analytics, get_claim_by_id as insurance_get_claim, get_coverage_limit as insurance_get_coverage_limit, - get_premium_rate as insurance_get_premium_rate, - initialize as insurance_initialize, - set_coverage_limit as insurance_set_coverage_limit, - submit_claim as insurance_submit_claim, InsuranceAnalytics, InsuranceClaim, InsuranceError, + get_premium_rate as insurance_get_premium_rate, initialize as insurance_initialize, + set_coverage_limit as insurance_set_coverage_limit, submit_claim as insurance_submit_claim, + InsuranceAnalytics, InsuranceClaim, InsuranceError, }; #[cfg(test)] @@ -185,11 +190,110 @@ impl LendingContract { Ok(()) } + pub fn commit_large_tx( + env: Env, + owner: Address, + operation: Symbol, + amount: i128, + quoted_output: i128, + min_output: i128, + max_slippage_bps: i128, + deadline: u64, + ) -> Result { + mev_protection::commit_large_tx( + &env, + owner, + operation, + amount, + quoted_output, + min_output, + max_slippage_bps, + deadline, + ) + } + + pub fn borrow_with_rate( + env: Env, + user: Address, + asset: Address, + amount: i128, + collateral_asset: Address, + collateral_amount: i128, + rate_type: RateType, + ) -> Result<(), BorrowError> { + borrow_with_rate_logic( + &env, + user, + asset, + amount, + collateral_asset, + collateral_amount, + rate_type, + ) + } + + pub fn set_variable_borrow_rate_bps( + env: Env, + admin: Address, + rate_bps: i128, + ) -> Result<(), BorrowError> { + set_variable_borrow_rate_logic(&env, &admin, rate_bps) + } + + pub fn switch_rate_type( + env: Env, + user: Address, + asset: Address, + to_rate_type: RateType, + ) -> Result<(), BorrowError> { + switch_rate_type_logic(&env, user, asset, to_rate_type) + } + + pub fn reveal_large_tx( + env: Env, + owner: Address, + commit_id: u64, + actual_output: i128, + ) -> Result { + mev_protection::reveal_large_tx(&env, owner, commit_id, actual_output) + } + + pub fn get_large_tx_commit( + env: Env, + commit_id: u64, + ) -> Option { + mev_protection::get_commit(&env, commit_id) + } + + pub fn record_lending_mev_gas( + env: Env, + reporter: Address, + operation: Symbol, + bid_microlumens: i128, + inclusion_delay_ledgers: u64, + ) -> Result { + mev_protection::record_gas_bid( + &env, + reporter, + operation, + bid_microlumens, + inclusion_delay_ledgers, + ) + } + + pub fn get_lending_mev_gas(env: Env, operation: Symbol) -> mev_protection::LendingGasBidStats { + mev_protection::get_gas_bid_stats(&env, operation) + } + /// Get user's debt position pub fn get_user_debt(env: Env, user: Address) -> DebtPosition { get_borrow_debt(&env, &user) } + pub fn get_user_debt_with_rate(env: Env, user: Address, rate_type: RateType) -> DebtPosition { + get_borrow_debt_with_rate(&env, &user, rate_type) + } + /// Get user's collateral position (borrow module) pub fn get_user_collateral(env: Env, user: Address) -> BorrowCollateral { get_borrow_collateral(&env, &user) diff --git a/stellar-lend/contracts/lending/src/math_safety_test.rs b/stellar-lend/contracts/lending/src/math_safety_test.rs index 21194944..570a1712 100644 --- a/stellar-lend/contracts/lending/src/math_safety_test.rs +++ b/stellar-lend/contracts/lending/src/math_safety_test.rs @@ -1,6 +1,7 @@ use crate::borrow::BorrowCollateral; use crate::borrow::{ calculate_interest, validate_collateral_ratio, BorrowDataKey, BorrowError, DebtPosition, + RateType, }; use crate::views::{collateral_value, compute_health_factor, HEALTH_FACTOR_NO_DEBT}; use crate::LendingContract; @@ -17,6 +18,8 @@ fn test_interest_calculation_extreme_values() { interest_accrued: 0, last_update: 0, asset: Address::generate(&env), + rate_type: RateType::Variable, + stable_rate_bps: 0, }; // Set ledger time to 1 year from now to keep result within i128 bounds @@ -36,6 +39,8 @@ fn test_interest_calculation_extreme_values() { interest_accrued: 0, last_update: 0, asset: Address::generate(&env), + rate_type: RateType::Variable, + stable_rate_bps: 0, }; env.ledger().with_mut(|li| li.timestamp = 3 * 31536000); @@ -93,6 +98,8 @@ fn test_interest_monotonic_for_large_ledger_jumps() { interest_accrued: 0, last_update: 0, asset: Address::generate(&env), + rate_type: RateType::Variable, + stable_rate_bps: 0, }; let checkpoints = [1u64, 10u64, 100u64, 500u64]; @@ -128,6 +135,8 @@ fn test_interest_returns_overflow_error_at_extreme_horizon() { interest_accrued: 0, last_update: 0, asset: Address::generate(&env), + rate_type: RateType::Variable, + stable_rate_bps: 0, }; env.ledger().with_mut(|li| li.timestamp = u64::MAX); @@ -149,6 +158,8 @@ fn test_get_user_debt_interest_addition_saturates() { interest_accrued: i128::MAX - 10, last_update: 0, asset: user.clone(), + rate_type: RateType::Variable, + stable_rate_bps: 0, }; env.storage() .persistent() diff --git a/stellar-lend/contracts/lending/src/mev_protection.rs b/stellar-lend/contracts/lending/src/mev_protection.rs new file mode 100644 index 00000000..184ee816 --- /dev/null +++ b/stellar-lend/contracts/lending/src/mev_protection.rs @@ -0,0 +1,201 @@ +//! Lightweight MEV guard primitives for the lending contract. +//! +//! The main liquidation batch auction lives in the core `hello-world` contract, +//! while this module gives the lending contract a compact commit/reveal and +//! gas-bid analysis surface for large transactions. + +use soroban_sdk::{contracterror, contracttype, Address, Env, Symbol}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum MevGuardError { + BadConfig = 1, + NotFound = 2, + Unauthorized = 3, + NotReady = 4, + Expired = 5, + SlippageExceeded = 6, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LendingMevCommitment { + pub owner: Address, + pub operation: Symbol, + pub amount: i128, + pub quoted_output: i128, + pub min_output: i128, + pub max_slippage_bps: i128, + pub reveal_after: u64, + pub deadline: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LendingGasBidStats { + pub samples: u64, + pub avg_bid_microlumens: i128, + pub max_bid_microlumens: i128, + pub avg_inclusion_delay_ledgers: u64, + pub last_updated: u64, +} + +#[contracttype] +#[derive(Clone)] +enum MevGuardKey { + NextCommitId, + Commit(u64), + Gas(Symbol), +} + +const MAX_BPS: i128 = 10_000; +const DEFAULT_REVEAL_DELAY_SECS: u64 = 30; + +pub fn commit_large_tx( + env: &Env, + owner: Address, + operation: Symbol, + amount: i128, + quoted_output: i128, + min_output: i128, + max_slippage_bps: i128, + deadline: u64, +) -> Result { + owner.require_auth(); + if amount <= 0 + || quoted_output <= 0 + || min_output < 0 + || min_output > quoted_output + || !(0..=MAX_BPS).contains(&max_slippage_bps) + || deadline <= env.ledger().timestamp() + { + return Err(MevGuardError::BadConfig); + } + + let id = next_commit_id(env); + let commitment = LendingMevCommitment { + owner, + operation, + amount, + quoted_output, + min_output, + max_slippage_bps, + reveal_after: env + .ledger() + .timestamp() + .saturating_add(DEFAULT_REVEAL_DELAY_SECS), + deadline, + }; + env.storage() + .persistent() + .set(&MevGuardKey::Commit(id), &commitment); + Ok(id) +} + +pub fn reveal_large_tx( + env: &Env, + owner: Address, + commit_id: u64, + actual_output: i128, +) -> Result { + owner.require_auth(); + let commitment = get_commit(env, commit_id).ok_or(MevGuardError::NotFound)?; + if commitment.owner != owner { + return Err(MevGuardError::Unauthorized); + } + + let now = env.ledger().timestamp(); + if now < commitment.reveal_after { + return Err(MevGuardError::NotReady); + } + if now > commitment.deadline { + return Err(MevGuardError::Expired); + } + + let slippage_floor = commitment + .quoted_output + .saturating_mul(MAX_BPS.saturating_sub(commitment.max_slippage_bps)) + .saturating_div(MAX_BPS); + if actual_output < commitment.min_output.max(slippage_floor) { + return Err(MevGuardError::SlippageExceeded); + } + + env.storage() + .persistent() + .remove(&MevGuardKey::Commit(commit_id)); + Ok(commitment) +} + +pub fn get_commit(env: &Env, commit_id: u64) -> Option { + env.storage() + .persistent() + .get(&MevGuardKey::Commit(commit_id)) +} + +pub fn record_gas_bid( + env: &Env, + reporter: Address, + operation: Symbol, + bid_microlumens: i128, + inclusion_delay_ledgers: u64, +) -> Result { + reporter.require_auth(); + if bid_microlumens <= 0 { + return Err(MevGuardError::BadConfig); + } + + let key = MevGuardKey::Gas(operation); + let mut stats = env + .storage() + .persistent() + .get(&key) + .unwrap_or(LendingGasBidStats { + samples: 0, + avg_bid_microlumens: 0, + max_bid_microlumens: 0, + avg_inclusion_delay_ledgers: 0, + last_updated: 0, + }); + let next_samples = stats.samples.saturating_add(1); + stats.avg_bid_microlumens = stats + .avg_bid_microlumens + .saturating_mul(i128::from(stats.samples)) + .saturating_add(bid_microlumens) + .saturating_div(i128::from(next_samples)); + stats.max_bid_microlumens = stats.max_bid_microlumens.max(bid_microlumens); + stats.avg_inclusion_delay_ledgers = stats + .avg_inclusion_delay_ledgers + .saturating_mul(stats.samples) + .saturating_add(inclusion_delay_ledgers) + .saturating_div(next_samples); + stats.samples = next_samples; + stats.last_updated = env.ledger().timestamp(); + env.storage().persistent().set(&key, &stats); + Ok(stats) +} + +pub fn get_gas_bid_stats(env: &Env, operation: Symbol) -> LendingGasBidStats { + env.storage() + .persistent() + .get(&MevGuardKey::Gas(operation)) + .unwrap_or(LendingGasBidStats { + samples: 0, + avg_bid_microlumens: 0, + max_bid_microlumens: 0, + avg_inclusion_delay_ledgers: 0, + last_updated: 0, + }) +} + +fn next_commit_id(env: &Env) -> u64 { + let id = env + .storage() + .persistent() + .get::(&MevGuardKey::NextCommitId) + .unwrap_or(1); + env.storage() + .persistent() + .set(&MevGuardKey::NextCommitId, &id.saturating_add(1)); + id +}