Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: CI
on: [push, pull_request]
on: [pull_request]

jobs:
check:
Expand Down
99 changes: 61 additions & 38 deletions contracts/tusdt-lending-pool/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ mod lending_pool {
}
}

/// Converts a scaled debt amount to its face value under a borrow index:
/// `floor(scaled × borrow_index / 1e18)`. Single source of truth for the
/// derived market `total_debt` — every mutation site recomputes the face
/// total from `total_scaled_debt` through this helper so it stays exactly
/// consistent with the per-user formula used by `get_user_debt`.
/// Returns `None` on overflow.
pub(crate) fn scaled_debt_to_face(scaled: Balance, index: Ratio) -> Option<Balance> {
index.checked_mul_value(scaled.into()).and_then(|v| Balance::try_from(v).ok())
}

/// Per-market runtime accrual state. Markets 0 (TAO) and 1 (TUSDT) are supply+borrow
/// markets with interest accrual. Markets 2+ are alpha collateral-only markets (one per
/// approved subnet); their MarketState exists but accrual is a no-op.
Expand All @@ -117,7 +127,17 @@ mod lending_pool {
/// the lTokens themselves, so this always equals the lToken supply.
pub total_supplied: Balance,
/// Total outstanding debt in underlying units (includes accrued interest).
/// Always derived as `floor(total_scaled_debt × borrow_index / 1e18)`
/// at every mutation site — see `total_scaled_debt`.
pub total_debt: Balance,
/// Total outstanding debt in scaled units (the Aave `drawnShares`
/// analog). This is the source of truth: borrow/repay/liquidate add or
/// subtract the SAME scaled value here and on the user position, so the
/// market total and the sum of per-user scaled positions move in exact
/// lockstep and can never drift. Face `total_debt` is recomputed from
/// this value on every mutation, and interest accrues into it purely
/// through borrow_index growth (no explicit total mutation).
pub total_scaled_debt: Balance,
/// Accumulator for per-user scaled debt: user_debt = scaled_debt × borrow_index.
/// Starts at 1.0 (1e18); grows at the borrow rate.
pub borrow_index: Ratio,
Expand All @@ -135,6 +155,7 @@ mod lending_pool {
Self {
total_supplied: 0,
total_debt: 0,
total_scaled_debt: 0,
borrow_index: Ratio::one(),
exchange_rate: Ratio::one(),
reserve_accrued: 0,
Expand Down Expand Up @@ -1437,8 +1458,14 @@ mod lending_pool {
.ok_or(Error::ArithmeticError)?;

let mut state = state;
state.total_debt =
state.total_debt.checked_add(amount).ok_or(Error::ArithmeticError)?;
// Lockstep scaled accounting: the market total gains the SAME
// scaled units as the position (Aave: total and user shares move
// together), so the ledger can never drift. The face total is
// derived from the scaled total for consumers (utilization, caps).
state.total_scaled_debt =
state.total_scaled_debt.checked_add(scaled).ok_or(Error::ArithmeticError)?;
state.total_debt = scaled_debt_to_face(state.total_scaled_debt, state.borrow_index)
.ok_or(Error::ArithmeticError)?;
self.markets.insert(0, &state);

let mut pos = self.positions.get((0, caller)).unwrap_or(Position {
Expand Down Expand Up @@ -1517,8 +1544,12 @@ mod lending_pool {
.ok_or(Error::ArithmeticError)?;

let mut state = state;
state.total_debt =
state.total_debt.checked_add(amount).ok_or(Error::ArithmeticError)?;
// Lockstep scaled accounting (see borrow_tao): the market total
// gains the SAME scaled units as the position.
state.total_scaled_debt =
state.total_scaled_debt.checked_add(scaled).ok_or(Error::ArithmeticError)?;
state.total_debt = scaled_debt_to_face(state.total_scaled_debt, state.borrow_index)
.ok_or(Error::ArithmeticError)?;
self.markets.insert(1, &state);

let mut pos = self.positions.get((1, caller)).unwrap_or(Position {
Expand Down Expand Up @@ -1577,22 +1608,12 @@ mod lending_pool {
return Ok(());
}

// Respect the market's tracked total debt. Ledger drift from
// historical scaling bugs can leave total_debt below the sum of
// user positions; subtracting more would underflow into
// ArithmeticError.
let repay_amount = min(repay_amount, state.total_debt);
if repay_amount == 0 {
self.set_idle();
return Ok(());
}

// scaled_repaid = repay_amount / borrow_index, rounded UP (Aave
// rayDivUp). A full repayment clears the position exactly, since
// floor division would otherwise strand the last sub-index unit of
// debt forever; a partial repayment must never erase the position
// through rounding. Clamped to the position so a drifted ledger
// can never underflow the subtraction.
// through rounding. Clamped to the position so the subtraction can
// never underflow.
let scaled_repaid = if repay_amount >= debt {
pos.scaled_debt
} else {
Expand Down Expand Up @@ -1627,8 +1648,13 @@ mod lending_pool {

// Effects
let mut state = state;
state.total_debt =
state.total_debt.checked_sub(repay_amount).ok_or(Error::ArithmeticError)?;
// Lockstep scaled accounting (see borrow_tao): the market total
// loses the SAME scaled units as the position, so a full repayment
// drives both to exactly zero.
state.total_scaled_debt =
state.total_scaled_debt.checked_sub(scaled_repaid).ok_or(Error::ArithmeticError)?;
state.total_debt = scaled_debt_to_face(state.total_scaled_debt, state.borrow_index)
.ok_or(Error::ArithmeticError)?;
self.markets.insert(0, &state);

let mut pos = pos;
Expand Down Expand Up @@ -1686,22 +1712,12 @@ mod lending_pool {
return Ok(());
}

// Respect the market's tracked total debt. Ledger drift from
// historical scaling bugs can leave total_debt below the sum of
// user positions; subtracting more would underflow into
// ArithmeticError.
let repay_amount = min(repay_amount, state.total_debt);
if repay_amount == 0 {
self.set_idle();
return Ok(());
}

// scaled_repaid = repay_amount / borrow_index, rounded UP (Aave
// rayDivUp). A full repayment clears the position exactly, since
// floor division would otherwise strand the last sub-index unit of
// debt forever; a partial repayment must never erase the position
// through rounding. Clamped to the position so a drifted ledger
// can never underflow the subtraction.
// through rounding. Clamped to the position so the subtraction can
// never underflow.
let scaled_repaid = if repay_amount >= debt {
pos.scaled_debt
} else {
Expand Down Expand Up @@ -1730,8 +1746,13 @@ mod lending_pool {

// Effects
let mut state = state;
state.total_debt =
state.total_debt.checked_sub(repay_amount).ok_or(Error::ArithmeticError)?;
// Lockstep scaled accounting (see borrow_tao): the market total
// loses the SAME scaled units as the position, so a full repayment
// drives both to exactly zero.
state.total_scaled_debt =
state.total_scaled_debt.checked_sub(scaled_repaid).ok_or(Error::ArithmeticError)?;
state.total_debt = scaled_debt_to_face(state.total_scaled_debt, state.borrow_index)
.ok_or(Error::ArithmeticError)?;
self.markets.insert(1, &state);

let mut pos = pos;
Expand Down Expand Up @@ -2076,10 +2097,8 @@ mod lending_pool {
} else {
min(cover_tusdt, borrower_debt)
};
// Respect the market's tracked total debt: never cover more debt
// than the market accounts for, or the subtraction underflows into
// ArithmeticError on a drifted ledger.
let actual_debt_units = min(actual_debt_units, state.total_debt);
// Never cover more than the borrower's actual debt; a zero amount
// (e.g. cover floor) has nothing to liquidate.
if actual_debt_units == 0 {
self.set_idle();
return Err(Error::ZeroAmount);
Expand Down Expand Up @@ -2129,8 +2148,12 @@ mod lending_pool {
);

let mut state = state;
state.total_debt =
state.total_debt.checked_sub(actual_debt_units).ok_or(Error::ArithmeticError)?;
// Lockstep scaled accounting (see borrow_tao): the market total
// loses the SAME scaled units as the borrower's position.
state.total_scaled_debt =
state.total_scaled_debt.checked_sub(scaled_repaid).ok_or(Error::ArithmeticError)?;
state.total_debt = scaled_debt_to_face(state.total_scaled_debt, state.borrow_index)
.ok_or(Error::ArithmeticError)?;
self.markets.insert(debt_market, &state);

let mut pos = pos;
Expand Down
20 changes: 13 additions & 7 deletions contracts/tusdt-lending-pool/rates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ impl TusdtLendingPool {
let mut state = self.markets.get(market_id).ok_or(Error::MarketNotFound)?;
let dt_ms = now.checked_sub(state.last_update).ok_or(Error::ArithmeticError)?;
let dt_hours = dt_ms / tusdt_primitives::MILLISECONDS_PER_HOUR;
if state.total_debt == 0 {
if state.total_scaled_debt == 0 {
// Nothing to accrue; keep the clock on real time. There is no
// debt whose sub-hour remainder could be starved.
if dt_ms > 0 {
Expand Down Expand Up @@ -89,11 +89,18 @@ impl TusdtLendingPool {
let supply_growth = ratio_add(one, supply_rate_hourly)
.and_then(|f| f.checked_pow(dt_hours.into()))
.ok_or(Error::ArithmeticError)?;
// Scaled-total accounting: the face total is derived from the
// scaled total at the NEW borrow index. Compounding a stale floored
// integer (the previous approach) drifted the total away from the
// sum of per-user `scaled_debt × borrow_index` floors — the root
// cause of unrepayable dust. The scaled total is never mutated
// here: interest accrues purely through index growth.
let new_borrow_index =
state.borrow_index.checked_mul(borrow_growth).ok_or(Error::ArithmeticError)?;
let debt_before = state.total_debt;
let new_debt = borrow_growth
.checked_mul_value(debt_before.into())
.and_then(|v| Balance::try_from(v).ok())
.ok_or(Error::ArithmeticError)?;
let new_debt =
scaled_debt_to_face(state.total_scaled_debt, new_borrow_index)
.ok_or(Error::ArithmeticError)?;
let debt_interest = new_debt.checked_sub(debt_before).ok_or(Error::ArithmeticError)?;
let new_exchange_rate =
state.exchange_rate.checked_mul(supply_growth).ok_or(Error::ArithmeticError)?;
Expand All @@ -103,8 +110,7 @@ impl TusdtLendingPool {
.unwrap_or(0);
let reserve_delta = debt_interest.saturating_sub(supply_interest);
state.total_debt = new_debt;
state.borrow_index =
state.borrow_index.checked_mul(borrow_growth).ok_or(Error::ArithmeticError)?;
state.borrow_index = new_borrow_index;
state.exchange_rate = new_exchange_rate;
state.reserve_accrued =
state.reserve_accrued.checked_add(reserve_delta).ok_or(Error::ArithmeticError)?;
Expand Down
Loading
Loading