diff --git a/stellar-lend/contracts/hello-world/README.md b/stellar-lend/contracts/hello-world/README.md
index 337247f..98906f1 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 615f713..7993d54 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 b8dba46..82b9e0b 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 2ff99c7..1118871 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 291908c..7efd3d6 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 7bf0b62..5d269d3 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 862865d..9eae263 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 c69ea29..29a9690 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 2119494..570a171 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 0000000..184ee81
--- /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
+}