diff --git a/stellar-lend/contracts/lending/src/borrow.rs b/stellar-lend/contracts/lending/src/borrow.rs index 7bf0b628..d4c4a95e 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); } @@ -252,6 +251,18 @@ fn get_stable_rate_state(env: &Env) -> StableRateState { } fn update_stable_rate_state_if_needed(env: &Env) -> StableRateState { + if !env + .storage() + .persistent() + .has(&BorrowDataKey::StableRateState) + { + let state = get_stable_rate_state(env); + env.storage() + .persistent() + .set(&BorrowDataKey::StableRateState, &state); + return state; + } + let mut state = get_stable_rate_state(env); let now = env.ledger().timestamp(); let interval = get_stable_rate_recalc_interval_secs(env); @@ -747,25 +758,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 +906,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 +921,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/borrow_test.rs b/stellar-lend/contracts/lending/src/borrow_test.rs index 4236ff1c..c2134ac0 100644 --- a/stellar-lend/contracts/lending/src/borrow_test.rs +++ b/stellar-lend/contracts/lending/src/borrow_test.rs @@ -1,5 +1,5 @@ use super::*; -use crate::borrow::calculate_interest; +use crate::borrow::{calculate_interest, RateType}; use soroban_sdk::{ testutils::{Address as _, Ledger}, Address, Env, diff --git a/stellar-lend/contracts/lending/src/deposit.rs b/stellar-lend/contracts/lending/src/deposit.rs index f35b2862..495573a9 100644 --- a/stellar-lend/contracts/lending/src/deposit.rs +++ b/stellar-lend/contracts/lending/src/deposit.rs @@ -5,7 +5,7 @@ pub use crate::events::VaultDepositEvent; pub type DepositEvent = VaultDepositEvent; use crate::pause::{self, PauseType}; -use soroban_sdk::{contracterror, contracttype, Address, Env}; +use soroban_sdk::{contracterror, contracttype, token, Address, Env}; /// Errors that can occur during deposit operations #[contracterror] @@ -29,6 +29,12 @@ pub enum DepositDataKey { TotalAmount, CapAmount, MinAmount, + AssetAccountedAmount(Address), + AssetTotalShares(Address), + DonationQuarantinedAmount(Address), + DonationAlert(Address), + DonationDefenseConfig, + DonationReport(Address), } /// User deposit position @@ -40,6 +46,33 @@ pub struct DepositCollateral { pub last_deposit_time: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DonationDefenseConfig { + pub virtual_assets: i128, + pub virtual_shares: i128, + pub max_unaccounted_bps: i128, + pub min_deposit_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DonationReport { + pub asset: Address, + pub accounted_balance: i128, + pub observed_balance: i128, + pub quarantined_balance: i128, + pub new_unaccounted_balance: i128, + pub virtual_share_price_bps: i128, + pub donation_detected: bool, + pub timestamp: u64, +} + +const BPS_SCALE: i128 = 10_000; +const DEFAULT_VIRTUAL_ASSETS: i128 = 1_000; +const DEFAULT_VIRTUAL_SHARES: i128 = 1_000; +const DEFAULT_MAX_UNACCOUNTED_BPS: i128 = 100; + /// Deposit collateral into the protocol /// /// # Arguments @@ -78,7 +111,7 @@ pub(crate) fn deposit_with_auth( return Err(DepositError::InvalidAmount); } - let min_deposit = get_min_deposit_amount(env); + let min_deposit = get_effective_min_deposit_amount(env); if amount < min_deposit { return Err(DepositError::InvalidAmount); } @@ -93,6 +126,8 @@ pub(crate) fn deposit_with_auth( return Err(DepositError::ExceedsDepositCap); } + add_asset_accounting(env, &asset, amount)?; + let mut position = get_deposit_position(env, &user, &asset); position.amount = position .amount @@ -123,10 +158,236 @@ pub fn initialize_deposit_settings( Ok(()) } +pub fn set_donation_defense_config( + env: &Env, + config: DonationDefenseConfig, +) -> Result<(), DepositError> { + validate_donation_config(&config)?; + env.storage() + .persistent() + .set(&DepositDataKey::DonationDefenseConfig, &config); + Ok(()) +} + +pub fn get_donation_defense_config(env: &Env) -> DonationDefenseConfig { + env.storage() + .persistent() + .get(&DepositDataKey::DonationDefenseConfig) + .unwrap_or_else(default_donation_config) +} + +pub fn sync_donation_balance(env: &Env, asset: &Address) -> Result { + let token_client = token::Client::new(env, asset); + let observed_balance = token_client.balance(&env.current_contract_address()); + sync_observed_balance(env, asset, observed_balance) +} + +pub fn sync_observed_balance( + env: &Env, + asset: &Address, + observed_balance: i128, +) -> Result { + if observed_balance < 0 { + return Err(DepositError::InvalidAmount); + } + + let accounted_balance = get_asset_accounted_amount(env, asset); + let quarantined_balance = get_donation_quarantined_amount(env, asset); + let expected_balance = accounted_balance + .checked_add(quarantined_balance) + .ok_or(DepositError::Overflow)?; + + let new_unaccounted_balance = if observed_balance > expected_balance { + observed_balance + .checked_sub(expected_balance) + .ok_or(DepositError::Overflow)? + } else { + 0 + }; + + let threshold = donation_detection_threshold(env, accounted_balance)?; + let donation_detected = new_unaccounted_balance > threshold; + let updated_quarantine = if new_unaccounted_balance > 0 { + quarantined_balance + .checked_add(new_unaccounted_balance) + .ok_or(DepositError::Overflow)? + } else { + quarantined_balance + }; + + if new_unaccounted_balance > 0 { + env.storage().persistent().set( + &DepositDataKey::DonationQuarantinedAmount(asset.clone()), + &updated_quarantine, + ); + } + if donation_detected { + env.storage() + .persistent() + .set(&DepositDataKey::DonationAlert(asset.clone()), &true); + } + + let report = DonationReport { + asset: asset.clone(), + accounted_balance, + observed_balance, + quarantined_balance: updated_quarantine, + new_unaccounted_balance, + virtual_share_price_bps: get_virtual_share_price_bps(env, asset)?, + donation_detected, + timestamp: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DepositDataKey::DonationReport(asset.clone()), &report); + Ok(report) +} + +pub fn acknowledge_donation(env: &Env, asset: &Address) { + env.storage() + .persistent() + .set(&DepositDataKey::DonationAlert(asset.clone()), &false); +} + +pub fn get_donation_report(env: &Env, asset: &Address) -> Option { + env.storage() + .persistent() + .get(&DepositDataKey::DonationReport(asset.clone())) +} + +pub fn is_donation_detected(env: &Env, asset: &Address) -> bool { + env.storage() + .persistent() + .get(&DepositDataKey::DonationAlert(asset.clone())) + .unwrap_or(false) +} + +pub fn get_virtual_share_price_bps(env: &Env, asset: &Address) -> Result { + let config = get_donation_defense_config(env); + let accounted_assets = get_asset_accounted_amount(env, asset) + .checked_add(config.virtual_assets) + .ok_or(DepositError::Overflow)?; + let total_shares = get_asset_total_shares(env, asset) + .checked_add(config.virtual_shares) + .ok_or(DepositError::Overflow)?; + + if total_shares <= 0 { + return Ok(BPS_SCALE); + } + + accounted_assets + .checked_mul(BPS_SCALE) + .ok_or(DepositError::Overflow)? + .checked_div(total_shares) + .ok_or(DepositError::Overflow) +} + +pub(crate) fn subtract_asset_accounting( + env: &Env, + asset: &Address, + amount: i128, +) -> Result<(), DepositError> { + if amount < 0 { + return Err(DepositError::InvalidAmount); + } + + let accounted = get_asset_accounted_amount(env, asset); + let shares = get_asset_total_shares(env, asset); + env.storage().persistent().set( + &DepositDataKey::AssetAccountedAmount(asset.clone()), + &accounted.checked_sub(amount).unwrap_or(0), + ); + env.storage().persistent().set( + &DepositDataKey::AssetTotalShares(asset.clone()), + &shares.checked_sub(amount).unwrap_or(0), + ); + Ok(()) +} + pub fn get_user_collateral(env: &Env, user: &Address, asset: &Address) -> DepositCollateral { get_deposit_position(env, user, asset) } +fn add_asset_accounting(env: &Env, asset: &Address, amount: i128) -> Result<(), DepositError> { + let accounted = get_asset_accounted_amount(env, asset) + .checked_add(amount) + .ok_or(DepositError::Overflow)?; + let shares = get_asset_total_shares(env, asset) + .checked_add(amount) + .ok_or(DepositError::Overflow)?; + + env.storage().persistent().set( + &DepositDataKey::AssetAccountedAmount(asset.clone()), + &accounted, + ); + env.storage() + .persistent() + .set(&DepositDataKey::AssetTotalShares(asset.clone()), &shares); + Ok(()) +} + +fn get_asset_accounted_amount(env: &Env, asset: &Address) -> i128 { + env.storage() + .persistent() + .get(&DepositDataKey::AssetAccountedAmount(asset.clone())) + .unwrap_or(0) +} + +fn get_asset_total_shares(env: &Env, asset: &Address) -> i128 { + env.storage() + .persistent() + .get(&DepositDataKey::AssetTotalShares(asset.clone())) + .unwrap_or(0) +} + +fn get_donation_quarantined_amount(env: &Env, asset: &Address) -> i128 { + env.storage() + .persistent() + .get(&DepositDataKey::DonationQuarantinedAmount(asset.clone())) + .unwrap_or(0) +} + +fn default_donation_config() -> DonationDefenseConfig { + DonationDefenseConfig { + virtual_assets: DEFAULT_VIRTUAL_ASSETS, + virtual_shares: DEFAULT_VIRTUAL_SHARES, + max_unaccounted_bps: DEFAULT_MAX_UNACCOUNTED_BPS, + min_deposit_amount: 0, + } +} + +fn validate_donation_config(config: &DonationDefenseConfig) -> Result<(), DepositError> { + if config.virtual_assets < 0 + || config.virtual_shares <= 0 + || config.max_unaccounted_bps < 0 + || config.max_unaccounted_bps > BPS_SCALE + || config.min_deposit_amount < 0 + { + return Err(DepositError::InvalidAmount); + } + Ok(()) +} + +fn get_effective_min_deposit_amount(env: &Env) -> i128 { + let base_min = get_min_deposit_amount(env); + let donation_min = get_donation_defense_config(env).min_deposit_amount; + if donation_min > base_min { + donation_min + } else { + base_min + } +} + +fn donation_detection_threshold(env: &Env, accounted_balance: i128) -> Result { + let config = get_donation_defense_config(env); + accounted_balance + .checked_mul(config.max_unaccounted_bps) + .ok_or(DepositError::Overflow)? + .checked_div(BPS_SCALE) + .ok_or(DepositError::Overflow) +} + fn get_deposit_position(env: &Env, user: &Address, asset: &Address) -> DepositCollateral { env.storage() .persistent() diff --git a/stellar-lend/contracts/lending/src/deposit_test.rs b/stellar-lend/contracts/lending/src/deposit_test.rs index e43df30c..2e3dc8ec 100644 --- a/stellar-lend/contracts/lending/src/deposit_test.rs +++ b/stellar-lend/contracts/lending/src/deposit_test.rs @@ -1,7 +1,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Ledger}, - Address, Env, + token, Address, Env, }; #[test] @@ -251,6 +251,122 @@ fn test_deposit_separate_users() { assert_eq!(pos2.amount, 20_000); } +#[test] +fn test_donation_detection_quarantines_unaccounted_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(LendingContract, ()); + let client = LendingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &asset); + + client.initialize(&admin, &1_000_000_000, &1000); + client.initialize_deposit_settings(&1_000_000_000, &100); + + client.deposit(&user, &asset, &10_000); + token_admin.mint(&contract_id, &10_000); + + let clean_report = client.sync_donation_balance(&asset); + assert!(!clean_report.donation_detected); + assert_eq!(clean_report.new_unaccounted_balance, 0); + assert_eq!(client.get_virtual_share_price_bps(&asset), 10_000); + + token_admin.mint(&contract_id, &5_000); + + let report = client.sync_donation_balance(&asset); + assert!(report.donation_detected); + assert_eq!(report.accounted_balance, 10_000); + assert_eq!(report.observed_balance, 15_000); + assert_eq!(report.new_unaccounted_balance, 5_000); + assert_eq!(report.quarantined_balance, 5_000); + + // Donation balance is quarantined and does not inflate the virtual share price. + assert_eq!(report.virtual_share_price_bps, 10_000); + assert_eq!(client.get_virtual_share_price_bps(&asset), 10_000); + + let stored = client.get_donation_report(&asset).unwrap(); + assert_eq!(stored, report); +} + +#[test] +fn test_donation_defense_minimum_deposit_blocks_dust() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(LendingContract, ()); + let client = LendingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + + client.initialize(&admin, &1_000_000_000, &1000); + client.initialize_deposit_settings(&1_000_000_000, &100); + client.set_donation_defense_config( + &admin, + &DonationDefenseConfig { + virtual_assets: 1_000, + virtual_shares: 1_000, + max_unaccounted_bps: 100, + min_deposit_amount: 1_000, + }, + ); + + let result = client.try_deposit(&user, &asset, &999); + assert_eq!(result, Err(Ok(DepositError::InvalidAmount))); + + assert_eq!(client.deposit(&user, &asset, &1_000), 1_000); +} + +#[test] +fn test_donation_alert_blocks_liquidation_until_acknowledged() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(LendingContract, ()); + let client = LendingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let borrower = Address::generate(&env); + let liquidator = Address::generate(&env); + let debt_asset = Address::generate(&env); + let collateral_asset = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_admin = token::StellarAssetClient::new(&env, &collateral_asset); + + client.initialize(&admin, &1_000_000_000, &1000); + token_admin.mint(&contract_id, &5_000); + + let report = client.sync_donation_balance(&collateral_asset); + assert!(report.donation_detected); + + let blocked = client.try_liquidate( + &liquidator, + &borrower, + &debt_asset, + &collateral_asset, + &1_000, + ); + assert_eq!(blocked, Err(Ok(BorrowError::ProtocolPaused))); + + client.acknowledge_donation(&admin, &collateral_asset); + let allowed = client.try_liquidate( + &liquidator, + &borrower, + &debt_asset, + &collateral_asset, + &1_000, + ); + assert!(allowed.is_ok()); +} + #[test] fn test_deposit_cap_boundary() { let env = Env::default(); 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..369e42df 100644 --- a/stellar-lend/contracts/lending/src/lib.rs +++ b/stellar-lend/contracts/lending/src/lib.rs @@ -5,21 +5,33 @@ mod borrow; mod deposit; mod events; mod flash_loan; +mod interest_rate; 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, + get_user_debt_with_rate as get_user_debt_with_rate_logic, 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, - initialize_deposit_settings as initialize_deposit_logic, DepositCollateral, DepositError, + acknowledge_donation as acknowledge_donation_logic, deposit as deposit_logic, + get_donation_defense_config as get_donation_defense_config_logic, + get_donation_report as get_donation_report_logic, + get_user_collateral as get_deposit_collateral, + get_virtual_share_price_bps as get_virtual_share_price_bps_logic, + initialize_deposit_settings as initialize_deposit_logic, is_donation_detected, + set_donation_defense_config as set_donation_defense_config_logic, + sync_donation_balance as sync_donation_balance_logic, DepositCollateral, DepositError, + DonationDefenseConfig, DonationReport, }; use flash_loan::{ flash_loan as flash_loan_logic, set_flash_loan_fee_bps as set_flash_loan_fee_logic, @@ -48,10 +60,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)] @@ -116,6 +127,46 @@ impl LendingContract { ) } + /// Borrow assets with an explicit variable/stable rate selection. + 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::borrow_with_rate( + &env, + user, + asset, + amount, + collateral_asset, + collateral_amount, + rate_type, + ) + } + + /// Update the variable borrow rate base model (admin only). + 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) + } + + /// Switch an existing debt position between variable and stable accounting. + pub fn switch_rate_type( + env: Env, + user: Address, + asset: Address, + rate_type: RateType, + ) -> Result<(), BorrowError> { + switch_rate_type_logic(&env, user, asset, rate_type) + } + /// Set protocol pause state for a specific operation (admin only) pub fn set_pause( env: Env, @@ -173,14 +224,18 @@ impl LendingContract { env: Env, liquidator: Address, _borrower: Address, - _debt_asset: Address, - _collateral_asset: Address, + debt_asset: Address, + collateral_asset: Address, _amount: i128, ) -> Result<(), BorrowError> { liquidator.require_auth(); if is_paused(&env, PauseType::Liquidation) { return Err(BorrowError::ProtocolPaused); } + if is_donation_detected(&env, &debt_asset) || is_donation_detected(&env, &collateral_asset) + { + return Err(BorrowError::ProtocolPaused); + } // Stub implementation, or call borrow::liquidate if it exists Ok(()) } @@ -190,6 +245,11 @@ impl LendingContract { get_borrow_debt(&env, &user) } + /// Get user's debt position for a specific rate bucket. + pub fn get_user_debt_with_rate(env: Env, user: Address, rate_type: RateType) -> DebtPosition { + get_user_debt_with_rate_logic(&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) @@ -269,6 +329,56 @@ impl LendingContract { ) -> DepositCollateral { get_deposit_collateral(&env, &user, &asset) } + + /// Configure donation attack defenses (admin only). + pub fn set_donation_defense_config( + env: Env, + admin: Address, + config: DonationDefenseConfig, + ) -> Result<(), DepositError> { + let current_admin = get_borrow_admin(&env).ok_or(DepositError::Unauthorized)?; + if admin != current_admin { + return Err(DepositError::Unauthorized); + } + admin.require_auth(); + set_donation_defense_config_logic(&env, config) + } + + /// Return the active donation defense configuration. + pub fn get_donation_defense_config(env: Env) -> DonationDefenseConfig { + get_donation_defense_config_logic(&env) + } + + /// Reconcile actual token balance against protocol accounting and quarantine donations. + pub fn sync_donation_balance(env: Env, asset: Address) -> Result { + sync_donation_balance_logic(&env, &asset) + } + + /// Return the latest donation reconciliation report for an asset. + pub fn get_donation_report(env: Env, asset: Address) -> Option { + get_donation_report_logic(&env, &asset) + } + + /// Return the donation-resistant virtual share price in basis points. + pub fn get_virtual_share_price_bps(env: Env, asset: Address) -> Result { + get_virtual_share_price_bps_logic(&env, &asset) + } + + /// Clear a donation alert after operational review (admin only). + pub fn acknowledge_donation( + env: Env, + admin: Address, + asset: Address, + ) -> Result<(), DepositError> { + let current_admin = get_borrow_admin(&env).ok_or(DepositError::Unauthorized)?; + if admin != current_admin { + return Err(DepositError::Unauthorized); + } + admin.require_auth(); + acknowledge_donation_logic(&env, &asset); + Ok(()) + } + /// Get protocol admin pub fn get_admin(env: Env) -> Option
{ get_borrow_admin(&env) 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/withdraw.rs b/stellar-lend/contracts/lending/src/withdraw.rs index fdc6c047..1d1e5c43 100644 --- a/stellar-lend/contracts/lending/src/withdraw.rs +++ b/stellar-lend/contracts/lending/src/withdraw.rs @@ -95,6 +95,11 @@ pub(crate) fn withdraw_with_auth( let total_deposits = get_total_deposits(env); let new_total = total_deposits.checked_sub(amount).unwrap_or(0); set_total_deposits(env, new_total); + crate::deposit::subtract_asset_accounting(env, &asset, amount).map_err(|e| match e { + crate::deposit::DepositError::InvalidAmount => WithdrawError::InvalidAmount, + crate::deposit::DepositError::Overflow => WithdrawError::Overflow, + _ => WithdrawError::Overflow, + })?; WithdrawEvent { user, diff --git a/stellar-lend/docs/donation-price-manipulation.md b/stellar-lend/docs/donation-price-manipulation.md new file mode 100644 index 00000000..79829a38 --- /dev/null +++ b/stellar-lend/docs/donation-price-manipulation.md @@ -0,0 +1,28 @@ +# Donation-Based Price Manipulation + +Direct token transfers to a lending contract can make the on-chain token balance +larger than the protocol's internal accounting. If pricing, share conversion, or +liquidation logic trusts the raw token balance, an attacker can donate assets to +temporarily inflate pool value, alter share price, and influence liquidation +decisions. + +The lending contract protects this surface by separating accounted assets from +observed token balances: + +- Deposits update per-asset accounted balances and share supply. +- `sync_donation_balance` compares the token balance against accounted assets + plus previously quarantined balance. +- New unaccounted balance above the configured tolerance raises a donation alert + and is quarantined instead of added to share price. +- `get_virtual_share_price_bps` calculates price from accounted assets plus + virtual assets, excluding quarantined donations. +- Liquidation entrypoints reject collateral assets with an active donation alert + until an admin acknowledges the event. + +Small legitimate balance changes, including airdrops, are handled as quarantined +unaccounted balance. They do not benefit depositors, do not inflate virtual share +price, and can be acknowledged by governance or an operator after review. + +Operators should keep `min_deposit_amount` high enough to make dust-based share +rounding attacks uneconomic and tune `max_unaccounted_bps` to match expected +asset transfer noise.