Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 40 additions & 32 deletions docs/ZERO_AMOUNT_SEMANTICS.md
Original file line number Diff line number Diff line change
@@ -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.
180 changes: 145 additions & 35 deletions stellar-lend/contracts/lending/src/borrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -564,6 +578,68 @@ pub fn repay_with_rate(
Ok(())
}

pub(crate) fn sweep_debt_dust(
env: &Env,
user: Address,
asset: Address,
) -> Result<i128, BorrowError> {
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,
Expand Down Expand Up @@ -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<i128, BorrowError> {
if position.borrowed_amount == 0 {
return Ok(0);
Expand All @@ -656,7 +742,6 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result<i
let current_time = env.ledger().timestamp();
let time_elapsed = current_time.saturating_sub(position.last_update);

let borrowed_256 = I256::from_i128(env, position.borrowed_amount);
let rate_bps = match position.rate_type {
RateType::Variable => get_current_variable_rate_bps(env)?,
RateType::Stable => {
Expand All @@ -667,14 +752,14 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result<i
}
}
};
let rate_256 = I256::from_i128(env, rate_bps);
let borrowed_256 = I256::from_i128(env, position.borrowed_amount);
let time_256 = I256::from_i128(env, time_elapsed as i128);

let mut interest_256 = borrowed_256
.mul(&rate_256)
.mul(&time_256)
.div(&I256::from_i128(env, 10000))
.div(&I256::from_i128(env, SECONDS_PER_YEAR as i128));
let denominator =
I256::from_i128(env, 10000).mul(&I256::from_i128(env, SECONDS_PER_YEAR as i128));
let base_numerator = borrowed_256
.mul(&I256::from_i128(env, rate_bps))
.mul(&time_256);
let mut interest_256 = div_ceil_i256(env, base_numerator, &denominator);

// Stability fee logic
if let Some(config) = get_stablecoin_config(env, &position.asset) {
Expand All @@ -690,12 +775,10 @@ pub(crate) fn calculate_interest(env: &Env, position: &DebtPosition) -> Result<i
};

if deviation_bps > 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 {
Expand Down Expand Up @@ -747,25 +830,30 @@ fn get_debt_position(
.persistent()
.get::<BorrowDataKey, DebtPosition>(&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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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()
Expand All @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion stellar-lend/contracts/lending/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
Expand Down
Loading