diff --git a/docs/ZERO_AMOUNT_SEMANTICS.md b/docs/ZERO_AMOUNT_SEMANTICS.md index a26ab7b..692ee48 100644 --- a/docs/ZERO_AMOUNT_SEMANTICS.md +++ b/docs/ZERO_AMOUNT_SEMANTICS.md @@ -1,43 +1,51 @@ -# Zero-Amount Operation Semantics +# Zero-Amount And Dust Semantics -This document specifies the expected behavior of all amount-bearing operations -in the StellarLend contracts when called with zero or negative amounts. +This document defines the amount-handling rules added for issue #380. -## Core Lending Operations +## Amount Rules -All core lending operations **reject** amounts ≤ 0 with their respective -`InvalidAmount` error variants. No state mutations occur on rejection. +The lending contract rejects all zero or negative amount-bearing operations: -| Operation | Zero / Negative Amount Result | -|------------------------|--------------------------------------------| -| `deposit_collateral` | `Err(DepositError::InvalidAmount)` | -| `withdraw_collateral` | `Err(WithdrawError::InvalidAmount)` | -| `borrow_asset` | `Err(BorrowError::InvalidAmount)` | -| `repay_debt` | `Err(RepayError::InvalidAmount)` | +| Operation | Rule | +| --- | --- | +| Deposit | `amount <= 0` is rejected with `DepositError::InvalidAmount` | +| Borrow | `amount <= 0` or `collateral_amount <= 0` is rejected with `BorrowError::InvalidAmount` | +| Repay | `amount <= 0` is rejected with `BorrowError::InvalidAmount` | +| Withdraw | `amount <= 0` is rejected with `WithdrawError::InvalidAmount` | -### Invariants +Configured minimum amounts are treated as dust thresholds. A positive amount +below the relevant minimum is dust and is rejected before any state mutation. -1. **No state mutation**: When an operation returns an error, storage (balances, - positions, analytics) must remain exactly as before the call. -2. **Clean revert**: The operation returns a typed `Result::Err`, not an - unhandled panic or abort. -3. **Composability**: A rejected zero-amount operation must not corrupt state - for subsequent valid operations. +## Dust Prevention -## Risk Management / Liquidation Functions +Deposits, borrows, and withdrawals already have configured minimum sizes. +The implementation now also prevents withdrawals and repayments from leaving +small residual balances: -These functions accept zero values and handle them gracefully: +- A withdrawal that would leave a non-zero deposit balance below + `MinWithdrawAmount` is rejected with `WithdrawError::DustAmount`. +- A repayment that would leave a non-zero debt balance below + `BorrowMinAmount` is rejected with `BorrowError::DustAmount`. -| Function | Zero-Value Behavior | -|-------------------------------------|-------------------------------------------| -| `can_be_liquidated(_, 0)` | `Ok(false)` — no debt means not liquidatable | -| `can_be_liquidated(0, debt)` | `Ok(true)` — zero collateral is liquidatable | -| `can_be_liquidated(0, 0)` | `Ok(false)` — no debt means not liquidatable | -| `get_max_liquidatable_amount(0)` | `Ok(0)` — nothing to liquidate | -| `get_liquidation_incentive_amount(0)` | `Ok(0)` — no incentive for zero amount | -| `require_min_collateral_ratio(_, 0)`| `Ok(())` — no debt always satisfies ratio | +These checks are constant-time comparisons and run after arithmetic validation +but before saving updated state. -## References +## Dust Sweep -- **Issue**: [#385 - Zero-Amount Operation Handling Tests](https://github.com/StellarLend/stellarlend-contracts/issues/385) -- **Test module**: `stellar-lend/contracts/hello-world/src/test_zero_amount.rs` +Users can clear existing dust that may have been created before a policy +change, migration, or manual recovery: + +- `sweep_deposit_dust(user, asset)` withdraws the user's full deposit balance + only when it is positive and below `MinWithdrawAmount`. +- `sweep_debt_dust(user, asset)` repays the user's full debt balance only when + it is positive and below `BorrowMinAmount`. + +Both sweep functions reject non-dust balances with the same `DustAmount` error +so they cannot be used to bypass normal minimum transaction sizes. + +## Rounding Direction + +Interest accrual uses depositor-friendly ceiling division: if a positive +interest calculation has any remainder, it rounds up by one unit instead of +rounding down to zero. Zero elapsed time and zero principal still accrue zero +interest. diff --git a/stellar-lend/contracts/lending/src/borrow.rs b/stellar-lend/contracts/lending/src/borrow.rs index 7bf0b62..de81498 100644 --- a/stellar-lend/contracts/lending/src/borrow.rs +++ b/stellar-lend/contracts/lending/src/borrow.rs @@ -15,6 +15,7 @@ pub use crate::events::{BorrowCollateralDepositEvent, BorrowEvent, RepayEvent}; /// Backward-compatible name for collateral added to a borrow position (see [`BorrowCollateralDepositEvent`]). pub type DepositEvent = BorrowCollateralDepositEvent; +use crate::dust::is_dust_amount; use crate::pause::{self, PauseType}; use soroban_sdk::{contracterror, contracttype, Address, Env, IntoVal, Symbol, I256}; @@ -53,6 +54,8 @@ pub enum BorrowError { PositionHealthy = 10, /// Insufficient reserves to recover bad debt InsufficientReserves = 11, + /// Operation would create or sweep a non-dust residual amount + DustAmount = 12, } /// Borrow on behalf of a user when authorization is provided via a trusted delegate. @@ -198,7 +201,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); } @@ -536,17 +538,29 @@ pub fn repay_with_rate( remaining_repayment = 0; } + let mut principal_repaid = 0; + // Repay principal if remaining_repayment > 0 { if remaining_repayment > debt_position.borrowed_amount { return Err(BorrowError::RepayAmountTooHigh); } debt_position.borrowed_amount -= remaining_repayment; + principal_repaid = remaining_repayment; + } + + let remaining_debt = debt_position + .borrowed_amount + .checked_add(debt_position.interest_accrued) + .ok_or(BorrowError::Overflow)?; + if is_dust_amount(remaining_debt, get_min_borrow_amount(env)) { + return Err(BorrowError::DustAmount); + } - // Update total protocol debt + if principal_repaid > 0 { let total_debt = get_total_debt(env); let new_total = total_debt - .checked_sub(remaining_repayment) + .checked_sub(principal_repaid) .ok_or(BorrowError::Overflow)?; set_total_debt(env, new_total); } @@ -564,6 +578,68 @@ pub fn repay_with_rate( Ok(()) } +pub(crate) fn sweep_debt_dust( + env: &Env, + user: Address, + asset: Address, +) -> Result { + let variable = get_debt_position(env, &user, Some(&asset), RateType::Variable); + let rate_type = if variable.borrowed_amount > 0 || variable.interest_accrued > 0 { + RateType::Variable + } else { + RateType::Stable + }; + + let mut debt_position = get_debt_position(env, &user, Some(&asset), rate_type); + debt_position.rate_type = rate_type; + + if debt_position.borrowed_amount == 0 && debt_position.interest_accrued == 0 { + return Err(BorrowError::InvalidAmount); + } + if debt_position.asset != asset { + return Err(BorrowError::AssetNotSupported); + } + + let accrued_interest = calculate_interest(env, &debt_position)?; + debt_position.interest_accrued = debt_position + .interest_accrued + .checked_add(accrued_interest) + .ok_or(BorrowError::Overflow)?; + debt_position.last_update = env.ledger().timestamp(); + + let total_due = debt_position + .borrowed_amount + .checked_add(debt_position.interest_accrued) + .ok_or(BorrowError::Overflow)?; + if !is_dust_amount(total_due, get_min_borrow_amount(env)) { + return Err(BorrowError::DustAmount); + } + + let principal_repaid = debt_position.borrowed_amount; + debt_position.borrowed_amount = 0; + debt_position.interest_accrued = 0; + + if principal_repaid > 0 { + let total_debt = get_total_debt(env); + let new_total = total_debt + .checked_sub(principal_repaid) + .ok_or(BorrowError::Overflow)?; + set_total_debt(env, new_total); + } + + save_debt_position(env, &user, &debt_position); + + RepayEvent { + user, + asset, + amount: total_due, + timestamp: env.ledger().timestamp(), + } + .publish(env); + + Ok(total_due) +} + pub fn switch_rate_type( env: &Env, user: Address, @@ -648,6 +724,16 @@ pub(crate) fn validate_collateral_ratio(collateral: i128, borrow: i128) -> Resul Ok(()) } +fn div_ceil_i256(env: &Env, numerator: I256, denominator: &I256) -> I256 { + let quotient = numerator.div(denominator); + let remainder = numerator.rem_euclid(denominator); + if remainder > I256::from_i128(env, 0) { + quotient.add(&I256::from_i128(env, 1)) + } else { + quotient + } +} + pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result { if position.borrowed_amount == 0 { return Ok(0); @@ -656,7 +742,6 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result get_current_variable_rate_bps(env)?, RateType::Stable => { @@ -667,14 +752,14 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result Result config.peg_threshold_bps { - let stability_fee_256 = borrowed_256 + let stability_fee_numerator = borrowed_256 .mul(&I256::from_i128(env, config.stability_fee_bps)) - .mul(&time_256) - .div(&I256::from_i128(env, 10000)) - .div(&I256::from_i128(env, SECONDS_PER_YEAR as i128)); - + .mul(&time_256); + let stability_fee_256 = div_ceil_i256(env, stability_fee_numerator, &denominator); interest_256 = interest_256.add(&stability_fee_256); crate::events::PegDeviationEvent { @@ -747,25 +830,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) { @@ -878,6 +966,10 @@ pub fn initialize_borrow_settings( min_borrow_amount: i128, ) -> Result<(), BorrowError> { // Note: ProtocolAdmin check should be performed by the caller (lib.rs) + if debt_ceiling <= 0 || min_borrow_amount <= 0 { + return Err(BorrowError::InvalidAmount); + } + env.storage() .persistent() .set(&BorrowDataKey::BorrowDebtCeiling, &debt_ceiling); @@ -888,11 +980,25 @@ pub fn initialize_borrow_settings( if !env .storage() .persistent() - .has(&BorrowDataKey::StableRatePremiumBps) + .has(&BorrowDataKey::StableRateState) { + 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::StableRatePremiumBps, &DEFAULT_STABLE_PREMIUM_BPS); + .set(&BorrowDataKey::StableRateState, &state); + } + if !env + .storage() + .persistent() + .has(&BorrowDataKey::StableRatePremiumBps) + { + env.storage().persistent().set( + &BorrowDataKey::StableRatePremiumBps, + &DEFAULT_STABLE_PREMIUM_BPS, + ); } if !env .storage() @@ -904,7 +1010,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/deposit.rs b/stellar-lend/contracts/lending/src/deposit.rs index f35b286..ee94edc 100644 --- a/stellar-lend/contracts/lending/src/deposit.rs +++ b/stellar-lend/contracts/lending/src/deposit.rs @@ -4,6 +4,7 @@ pub use crate::events::VaultDepositEvent; #[allow(dead_code)] pub type DepositEvent = VaultDepositEvent; +use crate::dust::is_dust_amount; use crate::pause::{self, PauseType}; use soroban_sdk::{contracterror, contracttype, Address, Env}; @@ -79,7 +80,7 @@ pub(crate) fn deposit_with_auth( } let min_deposit = get_min_deposit_amount(env); - if amount < min_deposit { + if is_dust_amount(amount, min_deposit) { return Err(DepositError::InvalidAmount); } @@ -114,6 +115,10 @@ pub fn initialize_deposit_settings( deposit_cap: i128, min_deposit_amount: i128, ) -> Result<(), DepositError> { + if deposit_cap <= 0 || min_deposit_amount <= 0 { + return Err(DepositError::InvalidAmount); + } + env.storage() .persistent() .set(&DepositDataKey::CapAmount, &deposit_cap); diff --git a/stellar-lend/contracts/lending/src/dust.rs b/stellar-lend/contracts/lending/src/dust.rs new file mode 100644 index 0000000..7399222 --- /dev/null +++ b/stellar-lend/contracts/lending/src/dust.rs @@ -0,0 +1,6 @@ +/// Returns true when `amount` is a positive value below the configured +/// minimum transaction size. +#[inline] +pub(crate) fn is_dust_amount(amount: i128, min_amount: i128) -> bool { + min_amount > 0 && amount > 0 && amount < min_amount +} diff --git a/stellar-lend/contracts/lending/src/dust_test.rs b/stellar-lend/contracts/lending/src/dust_test.rs new file mode 100644 index 0000000..f2b8725 --- /dev/null +++ b/stellar-lend/contracts/lending/src/dust_test.rs @@ -0,0 +1,175 @@ +use super::*; +use crate::borrow::{calculate_interest, BorrowDataKey}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, +}; + +fn setup(env: &Env) -> (Address, LendingContractClient<'_>) { + let contract_id = env.register(LendingContract, ()); + let client = LendingContractClient::new(env, &contract_id); + (contract_id, client) +} + +fn initialize(client: &LendingContractClient, admin: &Address) { + client.initialize(admin, &1_000_000_000, &1_000); + client.initialize_deposit_settings(&1_000_000_000, &1); + client.initialize_withdraw_settings(&100); +} + +#[test] +fn test_withdraw_rejects_leftover_dust() { + let env = Env::default(); + env.mock_all_auths(); + let (_contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + + initialize(&client, &admin); + client.deposit(&user, &asset, &150); + + let result = client.try_withdraw(&user, &asset, &100); + assert_eq!(result, Err(Ok(WithdrawError::DustAmount))); + + let position = client.get_user_collateral_deposit(&user, &asset); + assert_eq!(position.amount, 150); +} + +#[test] +fn test_sweep_deposit_dust_clears_existing_dust() { + let env = Env::default(); + env.mock_all_auths(); + let (_contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + + initialize(&client, &admin); + client.deposit(&user, &asset, &50); + + let swept = client.sweep_deposit_dust(&user, &asset); + assert_eq!(swept, 50); + + let position = client.get_user_collateral_deposit(&user, &asset); + assert_eq!(position.amount, 0); +} + +#[test] +fn test_sweep_deposit_dust_rejects_non_dust_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (_contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + + initialize(&client, &admin); + client.deposit(&user, &asset, &150); + + let result = client.try_sweep_deposit_dust(&user, &asset); + assert_eq!(result, Err(Ok(WithdrawError::DustAmount))); +} + +#[test] +fn test_repay_rejects_residual_debt_dust() { + let env = Env::default(); + env.mock_all_auths(); + let (_contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + let collateral_asset = Address::generate(&env); + + initialize(&client, &admin); + client.borrow(&user, &asset, &1_000, &collateral_asset, &1_500); + + let result = client.try_repay(&user, &asset, &500); + assert_eq!(result, Err(Ok(BorrowError::DustAmount))); + + let debt = client.get_user_debt(&user); + assert_eq!(debt.borrowed_amount, 1_000); +} + +#[test] +fn test_sweep_debt_dust_clears_existing_dust() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + + initialize(&client, &admin); + + env.as_contract(&contract_id, || { + let position = DebtPosition { + borrowed_amount: 500, + interest_accrued: 0, + last_update: env.ledger().timestamp(), + asset: asset.clone(), + rate_type: RateType::Variable, + stable_rate_bps: 0, + }; + env.storage().persistent().set( + &BorrowDataKey::BorrowUserVariableDebt(user.clone()), + &position, + ); + env.storage() + .persistent() + .set(&BorrowDataKey::BorrowUserDebt(user.clone()), &position); + env.storage() + .persistent() + .set(&BorrowDataKey::BorrowTotalDebt, &500_i128); + }); + + let swept = client.sweep_debt_dust(&user, &asset); + assert_eq!(swept, 500); + + let debt = client.get_user_debt(&user); + assert_eq!(debt.borrowed_amount, 0); + assert_eq!(debt.interest_accrued, 0); +} + +#[test] +fn test_sweep_debt_dust_rejects_non_dust_debt() { + let env = Env::default(); + env.mock_all_auths(); + let (_contract_id, client) = setup(&env); + let admin = Address::generate(&env); + let user = Address::generate(&env); + let asset = Address::generate(&env); + let collateral_asset = Address::generate(&env); + + initialize(&client, &admin); + client.borrow(&user, &asset, &1_000, &collateral_asset, &1_500); + + let result = client.try_sweep_debt_dust(&user, &asset); + assert_eq!(result, Err(Ok(BorrowError::DustAmount))); +} + +#[test] +fn test_interest_rounding_is_depositor_friendly() { + let env = Env::default(); + env.mock_all_auths(); + let (contract_id, _client) = setup(&env); + let asset = Address::generate(&env); + + env.ledger().with_mut(|li| { + li.timestamp = 1; + }); + + let position = DebtPosition { + borrowed_amount: 1_000, + interest_accrued: 0, + last_update: 0, + asset, + rate_type: RateType::Variable, + stable_rate_bps: 0, + }; + + let interest = env + .as_contract(&contract_id, || calculate_interest(&env, &position)) + .unwrap(); + assert_eq!(interest, 1); +} diff --git a/stellar-lend/contracts/lending/src/insurance.rs b/stellar-lend/contracts/lending/src/insurance.rs index 6369542..f8ac951 100644 --- a/stellar-lend/contracts/lending/src/insurance.rs +++ b/stellar-lend/contracts/lending/src/insurance.rs @@ -291,9 +291,7 @@ pub fn initialize(env: &Env, admin: &Address) -> Result<(), InsuranceError> { return Err(InsuranceError::AlreadyInitialized); } admin.require_auth(); - env.storage() - .persistent() - .set(&InsuranceKey::Admin, admin); + env.storage().persistent().set(&InsuranceKey::Admin, admin); Ok(()) } 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..8345280 100644 --- a/stellar-lend/contracts/lending/src/lib.rs +++ b/stellar-lend/contracts/lending/src/lib.rs @@ -3,19 +3,25 @@ use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Val, Vec}; mod borrow; mod deposit; +mod dust; 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, + 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, + sweep_debt_dust as borrow_sweep_debt_dust, 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, @@ -38,7 +44,8 @@ use views::{ use withdraw::{ initialize_withdraw_settings as initialize_withdraw_logic, - set_withdraw_paused as set_withdraw_paused_logic, withdraw as withdraw_logic, WithdrawError, + set_withdraw_paused as set_withdraw_paused_logic, + sweep_deposit_dust as sweep_deposit_dust_logic, withdraw as withdraw_logic, WithdrawError, }; mod data_store; mod insurance; @@ -48,10 +55,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)] @@ -61,6 +67,8 @@ mod data_store_test; #[cfg(test)] mod deposit_test; #[cfg(test)] +mod dust_test; +#[cfg(test)] mod flash_loan_test; #[cfg(test)] mod insurance_test; @@ -116,6 +124,46 @@ impl LendingContract { ) } + /// Borrow assets with explicit variable or 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_with_rate_logic( + &env, + user, + asset, + amount, + collateral_asset, + collateral_amount, + rate_type, + ) + } + + /// Switch an existing debt position between variable and stable rates. + 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) + } + + /// Set the variable borrow rate model base rate. + 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) + } + /// Set protocol pause state for a specific operation (admin only) pub fn set_pause( env: Env, @@ -141,6 +189,15 @@ impl LendingContract { borrow_repay(&env, user, asset, amount) } + /// Sweep a dust-sized debt balance that is below the minimum borrow size. + pub fn sweep_debt_dust(env: Env, user: Address, asset: Address) -> Result { + user.require_auth(); + if is_paused(&env, PauseType::Repay) { + return Err(BorrowError::ProtocolPaused); + } + borrow_sweep_debt_dust(&env, user, asset) + } + /// Deposit collateral into the protocol pub fn deposit( env: Env, @@ -190,6 +247,11 @@ impl LendingContract { get_borrow_debt(&env, &user) } + /// Get a 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_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) @@ -305,6 +367,18 @@ impl LendingContract { withdraw_logic(&env, user, asset, amount) } + /// Sweep an existing dust-sized deposit balance below the withdraw minimum. + pub fn sweep_deposit_dust( + env: Env, + user: Address, + asset: Address, + ) -> Result { + if is_paused(&env, PauseType::Withdraw) { + return Err(WithdrawError::WithdrawPaused); + } + sweep_deposit_dust_logic(&env, user, asset) + } + /// Initialize withdraw settings (admin only) pub fn initialize_withdraw_settings( env: Env, 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/withdraw.rs b/stellar-lend/contracts/lending/src/withdraw.rs index fdc6c04..e722d81 100644 --- a/stellar-lend/contracts/lending/src/withdraw.rs +++ b/stellar-lend/contracts/lending/src/withdraw.rs @@ -1,6 +1,7 @@ use soroban_sdk::{contracterror, contracttype, Address, Env}; use crate::deposit::{DepositCollateral, DepositDataKey}; +use crate::dust::is_dust_amount; pub use crate::events::WithdrawEvent; @@ -15,6 +16,7 @@ pub enum WithdrawError { InsufficientCollateral = 4, InsufficientCollateralRatio = 5, Unauthorized = 6, + DustAmount = 7, } /// Storage keys for withdraw-related data @@ -81,6 +83,9 @@ pub(crate) fn withdraw_with_auth( .amount .checked_sub(amount) .ok_or(WithdrawError::Overflow)?; + if is_dust_amount(new_amount, min_withdraw) { + return Err(WithdrawError::DustAmount); + } validate_collateral_ratio_after_withdraw(env, &user, new_amount)?; @@ -148,6 +153,10 @@ pub fn initialize_withdraw_settings( env: &Env, min_withdraw_amount: i128, ) -> Result<(), WithdrawError> { + if min_withdraw_amount <= 0 { + return Err(WithdrawError::InvalidAmount); + } + env.storage() .persistent() .set(&WithdrawDataKey::MinWithdrawAmount, &min_withdraw_amount); @@ -157,6 +166,44 @@ pub fn initialize_withdraw_settings( Ok(()) } +pub fn sweep_deposit_dust(env: &Env, user: Address, asset: Address) -> Result { + user.require_auth(); + + if is_paused(env) || crate::pause::is_paused(env, crate::pause::PauseType::Withdraw) { + return Err(WithdrawError::WithdrawPaused); + } + + let min_withdraw = get_min_withdraw_amount(env); + let position = get_collateral_position(env, &user, &asset); + if !is_dust_amount(position.amount, min_withdraw) { + return Err(WithdrawError::DustAmount); + } + + let updated_position = DepositCollateral { + amount: 0, + asset: asset.clone(), + last_deposit_time: position.last_deposit_time, + }; + save_collateral_position(env, &user, &updated_position); + + let total_deposits = get_total_deposits(env); + let new_total = total_deposits + .checked_sub(position.amount) + .ok_or(WithdrawError::Overflow)?; + set_total_deposits(env, new_total); + + WithdrawEvent { + user, + asset, + amount: position.amount, + remaining_balance: 0, + timestamp: env.ledger().timestamp(), + } + .publish(env); + + Ok(position.amount) +} + /// Set withdraw pause state pub fn set_withdraw_paused(env: &Env, paused: bool) -> Result<(), WithdrawError> { env.storage()