From 7b1272a80efe517aa0c2f20bf108836cd8ade848 Mon Sep 17 00:00:00 2001 From: benup211 Date: Sun, 23 Aug 2026 12:17:37 +0545 Subject: [PATCH 1/2] fix:track market debt in scaled units to clear repayment dust Borrowers repaying their full debt (principal + interest) were left with 1-2 rao of unrepayable dust: total_debt was tracked in face units while positions store scaled debt, so borrow (nominal add), accrue_interest (floor of stale integer) and the repay/liquidate min(repay, total_debt) clamp drifted the market total below the sum of user debts; at total_debt == 0 every further repay clamped to zero and the position could never be cleared. --- contracts/tusdt-lending-pool/lib.rs | 99 ++++++++++------ contracts/tusdt-lending-pool/rates.rs | 20 ++-- contracts/tusdt-lending-pool/tests.rs | 164 ++++++++++++++++++++++---- 3 files changed, 217 insertions(+), 66 deletions(-) diff --git a/contracts/tusdt-lending-pool/lib.rs b/contracts/tusdt-lending-pool/lib.rs index 52436f0..99d342d 100644 --- a/contracts/tusdt-lending-pool/lib.rs +++ b/contracts/tusdt-lending-pool/lib.rs @@ -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 { + 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. @@ -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, @@ -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, @@ -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 { @@ -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 { @@ -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 { @@ -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; @@ -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 { @@ -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; @@ -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); @@ -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; diff --git a/contracts/tusdt-lending-pool/rates.rs b/contracts/tusdt-lending-pool/rates.rs index f0ffdd4..4174e95 100644 --- a/contracts/tusdt-lending-pool/rates.rs +++ b/contracts/tusdt-lending-pool/rates.rs @@ -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 { @@ -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)?; @@ -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)?; diff --git a/contracts/tusdt-lending-pool/tests.rs b/contracts/tusdt-lending-pool/tests.rs index 548ca19..ba0af05 100644 --- a/contracts/tusdt-lending-pool/tests.rs +++ b/contracts/tusdt-lending-pool/tests.rs @@ -795,6 +795,7 @@ fn market_state_defaults() { let state = pool.get_market_state(0).unwrap(); assert_eq!(state.total_supplied, 0); assert_eq!(state.total_debt, 0); + assert_eq!(state.total_scaled_debt, 0); assert_eq!(state.borrow_index, Ratio::one()); assert_eq!(state.exchange_rate, Ratio::one()); } @@ -854,6 +855,7 @@ fn underlying_balance_view_quotes_exchange_rate() { MarketState { total_supplied: 90_909_090_909, total_debt: 0, + total_scaled_debt: 0, borrow_index: Ratio::one(), exchange_rate: Ratio::from_inner(1_100_000_000_000_000_000), reserve_accrued: 0, @@ -879,6 +881,7 @@ fn exchange_rate_resets_when_market_fully_drains() { let mut state = MarketState { total_supplied: 0, total_debt: 0, + total_scaled_debt: 0, borrow_index: Ratio::from_inner(1_050_000_000_000_000_000), exchange_rate: Ratio::from_inner(1_100_000_000_000_000_000), reserve_accrued: 0, @@ -900,6 +903,7 @@ fn exchange_rate_survives_debt_repayment_while_supplied() { let mut state = MarketState { total_supplied: 100_000_000_000, total_debt: 0, + total_scaled_debt: 0, borrow_index: Ratio::one(), exchange_rate: Ratio::from_inner(1_100_000_000_000_000_000), reserve_accrued: 0, @@ -1667,41 +1671,155 @@ fn borrow_scaling_rounds_up_to_never_understate_debt() { } #[ink::test] -fn repay_clamps_to_market_total_debt_when_ledger_drifted() { - // Regression: with total_debt below the user's position debt (ledger drift - // from the historical reversed-division bug), repaying the user's full debt - // underflowed `total_debt.checked_sub` into ArithmeticError. The repay now - // clamps to the market total; a zero clamped amount is a clean no-op that - // happens before the token pull. +fn full_repay_bookkeeping_clears_position_and_total_exactly() { + // Regression (production bug, 2026-08): with the market total tracked in + // face units and repay clamped to `min(repay, total_debt)`, a MAX + // repayment could leave the position stuck with dust (live signature: + // debt 1 rao on market 0, 2 rao on market 1, total_debt == 0, repay + // no-opping forever). With scaled-total accounting the market total and + // the position lose the SAME scaled units, so a full repayment always + // clears both exactly. The repay messages pull the ERC20 / transferred + // value before their effects and cannot run in the off-chain env, so the + // bookkeeping is pinned at the conversion level with the exact helpers + // repay_tao/repay_tusdt/liquidate use. + let index = Ratio::from_inner(1_000_316_946_018_546_683); // live testnet index + + // Live-chain stuck signature: scaled 1 at this index displays debt 1 + // (market 0), scaled 2 displays debt 2 (market 1). + for scaled in [1_u128, 2] { + let debt = index + .checked_mul_value(scaled) + .expect("debt recompute must not overflow"); + assert_eq!(debt, scaled, "scaled {scaled} displays exactly {scaled} rao of debt"); + + // repay_amount = min(MAX, debt) = debt → the full-repay branch sets + // scaled_repaid = pos.scaled_debt. The ceil-on-borrow / floor-on- + // display pairing must make the round trip exact. + let scaled_repaid = index + .checked_div_value_ceil(debt) + .expect("full repay scaling must not overflow"); + assert_eq!(scaled_repaid, scaled, "full repay of displayed debt clears scaled exactly"); + + // Market total side (lockstep): total_scaled_debt -= scaled_repaid + // lands on exactly 0 and the derived face follows. + let remaining_scaled = scaled.checked_sub(scaled_repaid).expect("no underflow"); + assert_eq!(remaining_scaled, 0, "no scaled dust survives the last repay"); + assert_eq!( + scaled_debt_to_face(0, index), + Some(0), + "face total hits exactly 0 — position gone, utilization 0" + ); + } +} + +#[ink::test] +fn scaled_total_keeps_face_total_at_least_the_sum_of_user_debts() { + // Invariant the fix restores: face total = floor(Σscaled × index) is + // always >= every user's displayed debt floor(scaled_i × index) (floor is + // monotonic), so a MAX repayment can never be truncated by a total-debt + // clamp and the LAST borrower can always clear the market. This is what + // the removed `min(repay, total_debt)` clamp previously violated once + // independent floor rounding had drifted the face total down. + let index = Ratio::from_inner(1_400_000_000_000_000_000); // 1.4 + let a: u64 = 2; + let b: u64 = 3; + let total_scaled = 5u64; + + let total_face = scaled_debt_to_face(total_scaled, index).unwrap(); + let a_face = scaled_debt_to_face(a, index).unwrap(); + let b_face = scaled_debt_to_face(b, index).unwrap(); + assert_eq!((total_face, a_face, b_face), (7, 2, 4)); + assert!(total_face >= a_face && total_face >= b_face, "every user is fully repayable"); + + // A fully repays: scaled_repaid = ceil(a_face / index) == a exactly. + let a_repaid = index + .checked_div_value_ceil(a_face.into()) + .expect("full repay scaling must not overflow"); + assert_eq!(a_repaid as u64, a, "full repay round-trips the position's scaled debt"); + let remaining_scaled = total_scaled.checked_sub(a).unwrap(); + let remaining_face = scaled_debt_to_face(remaining_scaled, index).unwrap(); + assert!(remaining_face >= b_face, "B is still fully repayable after A exits"); + + // B (the last borrower) fully repays: the market hits exact zero. + let b_repaid = index + .checked_div_value_ceil(b_face.into()) + .expect("full repay scaling must not overflow"); + assert_eq!(b_repaid as u64, b, "last full repay round-trips exactly"); + assert_eq!( + scaled_debt_to_face(remaining_scaled.checked_sub(b).unwrap(), index), + Some(0), + "last repay drives the face total to exactly 0 — no ghost dust" + ); +} + +#[ink::test] +fn accrue_interest_derives_face_total_from_scaled_total() { + // Regression: accrual used to compound the face total independently + // (`floor(total_debt × growth)` on a stale floored integer), drifting it + // below the sum of per-user debts — the interest-driven engine of the + // unrepayable-dust bug. Now the scaled total is never mutated by accrual + // (interest accrues purely through index growth) and the face total is + // re-derived from it at the new index, keeping both in lockstep. + // Market 0 (TAO) is used because market_cash needs no cross-contract call + // in the off-chain env; the accrual path under test is shared. let (mut pool, accounts) = setup(); set_caller(accounts.alice); + let scaled: u64 = 128_000_000_000; // 128 TAO (9 decimals) pool.debug_set_market_state( - 1, + 0, MarketState { - total_supplied: 129_000_000_000, - total_debt: 0, // fully drained by drift + total_supplied: 130_000_000_000, + total_debt: scaled, // index 1.0 → face == scaled + total_scaled_debt: scaled, borrow_index: Ratio::one(), exchange_rate: Ratio::one(), reserve_accrued: 0, last_update: 0, }, ); - pool.debug_set_position( - 1, - accounts.alice, - Position { ltoken_balance: 0, scaled_debt: 12, alpha_principal: 0 }, + + ink::env::test::set_block_timestamp::( + tusdt_primitives::MILLISECONDS_PER_HOUR + 1, ); - pool.debug_set_debt_principal(1, accounts.alice, 12); + pool.accrue_interest(0).unwrap(); - // Repaying the full debt against an empty market total must not underflow: - // it clamps to zero and no-ops before touching the token contract. - assert_eq!(pool.repay_tusdt(12), Ok(())); + let state = pool.get_market_state(0).unwrap(); + assert_eq!(state.total_scaled_debt, scaled, "scaled total is never mutated by accrual"); + assert_eq!( + state.total_debt, + scaled_debt_to_face(scaled, state.borrow_index).unwrap(), + "face total is exactly floor(total_scaled_debt × borrow_index / 1e18)" + ); + assert!(state.total_debt >= scaled, "interest accrues into the face total"); +} + +#[ink::test] +fn liquidate_bookkeeping_subtracts_the_same_scaled_units_from_total_and_position() { + // The liquidate message ends in cross-contract calls (oracle/ERC20) that + // cannot run off-chain; pin its debt bookkeeping at the conversion level + // with the exact helpers it uses. A full-debt liquidation must clear the + // position AND shrink the market total by the SAME scaled delta, so the + // borrower's dust can never migrate into the market total. + let index = Ratio::from_inner(1_400_000_000_000_000_000); // 1.4 + let pos_scaled: u64 = 3; + let total_scaled: u64 = pos_scaled + 5; // another borrower holds 5 scaled units + + let borrower_debt = scaled_debt_to_face(pos_scaled, index).unwrap(); + assert_eq!(borrower_debt, 4); + + // liquidate: actual_debt_units = min(cover, borrower_debt) = borrower_debt + // (full cover) → scaled_repaid = min(ceil(borrower_debt / index), pos_scaled). + let scaled_repaid = index + .checked_div_value_ceil(borrower_debt.into()) + .expect("ceil scaling must not overflow"); + assert_eq!(scaled_repaid as u64, pos_scaled, "full liquidation round-trips exactly"); - let state = pool.get_market_state(1).unwrap(); - assert_eq!(state.total_debt, 0, "no debt should have been deducted"); - let (debt, principal) = pool.get_user_debt_details(1, accounts.alice).unwrap(); - assert_eq!((debt, principal), (12, 12), "position untouched"); + // total_scaled_debt -= scaled_repaid → the other borrower's 5 scaled units + // remain and the derived face total tracks them exactly. + let remaining_scaled = total_scaled.checked_sub(pos_scaled).unwrap(); + let remaining_face = scaled_debt_to_face(remaining_scaled, index).unwrap(); + assert_eq!((remaining_scaled, remaining_face), (5, 7)); } #[ink::test] @@ -1769,6 +1887,7 @@ fn accrue_market_interest_refreshes_both_markets_permissionlessly() { MarketState { total_supplied: 130_000_000_000, total_debt: 128_000_000_000, + total_scaled_debt: 128_000_000_000, borrow_index: Ratio::one(), exchange_rate: Ratio::one(), reserve_accrued: 0, @@ -1780,6 +1899,7 @@ fn accrue_market_interest_refreshes_both_markets_permissionlessly() { MarketState { total_supplied: 129_000_000_000, total_debt: 0, + total_scaled_debt: 0, borrow_index: Ratio::one(), exchange_rate: Ratio::one(), reserve_accrued: 0, @@ -1838,6 +1958,7 @@ fn accrual_produces_interest_after_an_hour() { MarketState { total_supplied: 130_000_000_000, total_debt: debt, + total_scaled_debt: debt, borrow_index: Ratio::one(), exchange_rate: Ratio::one(), reserve_accrued: 0, @@ -1889,6 +2010,7 @@ fn sub_hour_remainder_is_not_discarded() { MarketState { total_supplied: 130_000_000_000, total_debt: debt, + total_scaled_debt: debt, borrow_index: Ratio::one(), exchange_rate: Ratio::one(), reserve_accrued: 0, From 65c2767dfc4fd69c65e05a10814c61a3a8bd4bd3 Mon Sep 17 00:00:00 2001 From: benup211 Date: Sun, 23 Aug 2026 20:13:10 +0545 Subject: [PATCH 2/2] fix: ci only run on pull request --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6d1e6d..b52d859 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ name: CI -on: [push, pull_request] +on: [pull_request] jobs: check: