diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b158a..a6d1e6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # No explicit toolchain/target: the action reads rust-toolchain.toml, + # which pins channel 1.89 plus the cargo/clippy/rustfmt components and + # the wasm32-unknown-unknown target, keeping CI and local dev in sync. - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: 1.89.0 - target: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - name: Format run: cargo fmt --all -- --check diff --git a/contracts/tusdt-lending-pool/lib.rs b/contracts/tusdt-lending-pool/lib.rs index 19df8d3..52436f0 100644 --- a/contracts/tusdt-lending-pool/lib.rs +++ b/contracts/tusdt-lending-pool/lib.rs @@ -66,6 +66,43 @@ mod lending_pool { TUSDT = 1, } + /// lTokens to mint for a supply of `amount` underlying at + /// `exchange_rate` (floored). Minting at a grown rate hands the depositor + /// at most their exact share of the pool, so late deposits can never + /// dilute earlier suppliers' accrued interest. Returns `None` on a zero + /// rate or overflow. `exchange_rate` is `>= 1e18` in practice. + pub(crate) fn compute_mint_amount(amount: Balance, exchange_rate: Ratio) -> Option { + // checked_div_value(value) computes value / self — the divisor is the rate. + exchange_rate.checked_div_value(amount.into()).and_then(|v| Balance::try_from(v).ok()) + } + + /// Underlying redeemable for `ltoken_amount` at `exchange_rate` + /// (floored — the pool never pays out more than the lTokens are worth; + /// the sub-rao dust stays with the pool). Returns `None` on overflow. + pub(crate) fn compute_redeem_amount( + ltoken_amount: Balance, + exchange_rate: Ratio, + ) -> Option { + // checked_mul_value(value) computes value * self. + exchange_rate + .checked_mul_value(ltoken_amount.into()) + .and_then(|v| Balance::try_from(v).ok()) + } + + /// Resets the lToken exchange rate to 1.0 once the market has fully + /// drained (`total_supplied == 0`). The exchange rate only ever grows in + /// `accrue_interest`, so without this reset the next genesis supply (which + /// mints 1:1 at the empty-market branch) would be credited at the stale + /// grown rate — the new supplier's lTokens would claim more underlying + /// than they deposited, leaving the pool short. The borrow index is left + /// untouched: it is self-consistent for scaled debt, and resetting it + /// could corrupt drifted legacy positions. + pub(crate) fn reset_exchange_rate_when_drained(state: &mut MarketState) { + if state.total_supplied == 0 { + state.exchange_rate = Ratio::one(); + } + } + /// 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. @@ -73,7 +110,11 @@ mod lending_pool { #[ink::scale_derive(Encode, Decode, TypeInfo)] #[cfg_attr(feature = "std", derive(ink::storage::traits::StorageLayout))] pub struct MarketState { - /// Total underlying supplied (principal deposits only — not including accrued interest). + /// Total underlying supplied, tracked in lToken (scaled) units: the + /// face value owed to suppliers is `total_supplied × exchange_rate`, + /// and accrued interest grows it through the exchange rate alone. + /// Supply mints `amount / exchange_rate` lTokens and withdrawal burns + /// the lTokens themselves, so this always equals the lToken supply. pub total_supplied: Balance, /// Total outstanding debt in underlying units (includes accrued interest). pub total_debt: Balance, @@ -957,11 +998,17 @@ mod lending_pool { self.set_idle(); })?; - // Cap check + // Cap check. `total_supplied` is tracked in lToken (scaled) + // units; project the face value at the current exchange rate so + // the cap keeps its face-amount semantics. if self.global_params.supply_cap_tao > 0 { let state = self.markets.get(0).ok_or(Error::MarketNotFound)?; - let projected = - state.total_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; + let face_supplied = state + .exchange_rate + .checked_mul_value(state.total_supplied.into()) + .and_then(|v| Balance::try_from(v).ok()) + .ok_or(Error::ArithmeticError)?; + let projected = face_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; if projected > self.global_params.supply_cap_tao { self.set_idle(); return Err(Error::SupplyCapExceeded); @@ -972,16 +1019,16 @@ mod lending_pool { let ltoken_addr = self.ltoken_by_market.get(0).ok_or(Error::MarketNotFound)?; let mut ltoken = TusdtErc20Ref::from_account_id(ltoken_addr); - // Compute lToken amount: amount * ltoken_total_supply / total_supplied + // Compute lToken amount: amount / exchange_rate (1:1 at genesis). + // The exchange rate grows with accrued supply interest, so a + // deposit at a grown rate mints proportionally fewer lTokens and + // interest accrued before the deposit stays with earlier + // suppliers. let ltoken_supply = ltoken.total_supply(); let ltoken_scaled = if ltoken_supply == 0 || state.total_supplied == 0 { amount } else { - Ratio::from_integer(amount.into()) - .checked_mul_value(ltoken_supply.into()) - .and_then(|v| v.checked_div(state.total_supplied as u128)) - .and_then(|v| u64::try_from(v).ok()) - .unwrap_or(0) + compute_mint_amount(amount, state.exchange_rate).ok_or(Error::ArithmeticError)? }; if ltoken_scaled == 0 { self.set_idle(); @@ -991,8 +1038,14 @@ mod lending_pool { // Effects: update market state and position let caller = self.env().caller(); let mut state = self.markets.get(0).ok_or(Error::MarketNotFound)?; + if state.total_supplied == 0 { + // Genesis supply on a fully drained market: restart the + // exchange rate at 1.0 so the 1:1 genesis mint is backed 1:1. + // A stale grown rate would over-credit the new supplier. + state.exchange_rate = Ratio::one(); + } state.total_supplied = - state.total_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; + state.total_supplied.checked_add(ltoken_scaled).ok_or(Error::ArithmeticError)?; self.markets.insert(0, &state); let mut pos = self.positions.get((0, caller)).unwrap_or(Position { @@ -1043,11 +1096,17 @@ mod lending_pool { self.set_idle(); })?; - // Cap check + // Cap check. `total_supplied` is tracked in lToken (scaled) + // units; project the face value at the current exchange rate so + // the cap keeps its face-amount semantics. if self.global_params.supply_cap_tusdt > 0 { let state = self.markets.get(1).ok_or(Error::MarketNotFound)?; - let projected = - state.total_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; + let face_supplied = state + .exchange_rate + .checked_mul_value(state.total_supplied.into()) + .and_then(|v| Balance::try_from(v).ok()) + .ok_or(Error::ArithmeticError)?; + let projected = face_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; if projected > self.global_params.supply_cap_tusdt { self.set_idle(); return Err(Error::SupplyCapExceeded); @@ -1066,16 +1125,16 @@ mod lending_pool { let ltoken_addr = self.ltoken_by_market.get(1).ok_or(Error::MarketNotFound)?; let mut ltoken = TusdtErc20Ref::from_account_id(ltoken_addr); - // Compute lToken amount + // Compute lToken amount: amount / exchange_rate (1:1 at genesis). + // The exchange rate grows with accrued supply interest, so a + // deposit at a grown rate mints proportionally fewer lTokens and + // interest accrued before the deposit stays with earlier + // suppliers. let ltoken_supply = ltoken.total_supply(); let ltoken_scaled = if ltoken_supply == 0 || state.total_supplied == 0 { amount } else { - Ratio::from_integer(amount.into()) - .checked_mul_value(ltoken_supply.into()) - .and_then(|v| v.checked_div(state.total_supplied as u128)) - .and_then(|v| u64::try_from(v).ok()) - .unwrap_or(0) + compute_mint_amount(amount, state.exchange_rate).ok_or(Error::ArithmeticError)? }; if ltoken_scaled == 0 { self.set_idle(); @@ -1084,8 +1143,14 @@ mod lending_pool { // Effects let mut state = self.markets.get(1).ok_or(Error::MarketNotFound)?; + if state.total_supplied == 0 { + // Genesis supply on a fully drained market: restart the + // exchange rate at 1.0 so the 1:1 genesis mint is backed 1:1. + // A stale grown rate would over-credit the new supplier. + state.exchange_rate = Ratio::one(); + } state.total_supplied = - state.total_supplied.checked_add(amount).ok_or(Error::ArithmeticError)?; + state.total_supplied.checked_add(ltoken_scaled).ok_or(Error::ArithmeticError)?; self.markets.insert(1, &state); let mut pos = self.positions.get((1, caller)).unwrap_or(Position { @@ -1142,16 +1207,15 @@ mod lending_pool { return Err(Error::InsufficientLTokenBalance); } - // Compute underlying amount - let ltoken_supply = ltoken.total_supply(); - let underlying = if ltoken_supply == 0 || state.total_supplied == 0 { + // Compute underlying amount: ltoken_amount × exchange_rate. The + // exchange rate grew with accrued supply interest, so this + // includes the supplier's proportional share of borrower interest + // (net of the reserve slice) — not just the principal. + let underlying = if state.total_supplied == 0 { 0 } else { - Ratio::from_integer(ltoken_amount.into()) - .checked_mul_value(state.total_supplied.into()) - .and_then(|v| v.checked_div(ltoken_supply as u128)) - .and_then(|v| u64::try_from(v).ok()) - .unwrap_or(0) + compute_redeem_amount(ltoken_amount, state.exchange_rate) + .ok_or(Error::ArithmeticError)? }; if underlying == 0 { self.set_idle(); @@ -1185,7 +1249,8 @@ mod lending_pool { let mut state = self.markets.get(0).ok_or(Error::MarketNotFound)?; state.total_supplied = - state.total_supplied.checked_sub(underlying).ok_or(Error::ArithmeticError)?; + state.total_supplied.checked_sub(ltoken_amount).ok_or(Error::ArithmeticError)?; + reset_exchange_rate_when_drained(&mut state); self.markets.insert(0, &state); let mut pos = self.positions.get((0, caller)).unwrap_or(Position { @@ -1240,15 +1305,15 @@ mod lending_pool { return Err(Error::InsufficientLTokenBalance); } - let ltoken_supply = ltoken.total_supply(); - let underlying = if ltoken_supply == 0 || state.total_supplied == 0 { + // Compute underlying amount: ltoken_amount × exchange_rate. The + // exchange rate grew with accrued supply interest, so this + // includes the supplier's proportional share of borrower interest + // (net of the reserve slice) — not just the principal. + let underlying = if state.total_supplied == 0 { 0 } else { - Ratio::from_integer(ltoken_amount.into()) - .checked_mul_value(state.total_supplied.into()) - .and_then(|v| v.checked_div(ltoken_supply as u128)) - .and_then(|v| u64::try_from(v).ok()) - .unwrap_or(0) + compute_redeem_amount(ltoken_amount, state.exchange_rate) + .ok_or(Error::ArithmeticError)? }; if underlying == 0 { self.set_idle(); @@ -1266,7 +1331,8 @@ mod lending_pool { let mut state = self.markets.get(1).ok_or(Error::MarketNotFound)?; state.total_supplied = - state.total_supplied.checked_sub(underlying).ok_or(Error::ArithmeticError)?; + state.total_supplied.checked_sub(ltoken_amount).ok_or(Error::ArithmeticError)?; + reset_exchange_rate_when_drained(&mut state); self.markets.insert(1, &state); let mut pos = self.positions.get((1, caller)).unwrap_or(Position { @@ -1932,8 +1998,15 @@ mod lending_pool { self.set_idle(); })?; - // Accrue interest on the debt market - self.accrue_interest(debt_market).inspect_err(|_| { + // Accrue interest on BOTH debt markets: the health factor and the + // close-factor cap read the borrower's debt on both markets, so a + // stale borrow index on the non-liquidated market would understate + // the accrued interest and let the liquidation cover too little + // (principal + accrued interest must both be settled). + self.accrue_interest(0).inspect_err(|_| { + self.set_idle(); + })?; + self.accrue_interest(1).inspect_err(|_| { self.set_idle(); })?; @@ -2918,21 +2991,16 @@ mod lending_pool { } /// Returns the underlying amount a user's lToken balance is worth in a - /// market, or `None`. + /// market (principal plus their accrued share of borrower interest), + /// or `None` for alpha markets or an unknown user/market. #[ink(message)] pub fn get_underlying_balance(&self, market_id: u8, user: AccountId) -> Option { + if market_id > 1 { + return None; // alpha markets have no lToken + } let pos = self.positions.get((market_id, user))?; let state = self.markets.get(market_id)?; - let ltoken_addr = self.ltoken_by_market.get(market_id)?; - let ltoken = TusdtErc20Ref::from_account_id(ltoken_addr); - let ltoken_supply = ltoken.total_supply(); - if ltoken_supply == 0 || state.total_supplied == 0 { - return Some(0); - } - Ratio::from_integer(pos.ltoken_balance.into()) - .checked_mul_value(state.total_supplied.into()) - .and_then(|v| v.checked_div(ltoken_supply as u128)) - .and_then(|v| u64::try_from(v).ok()) + compute_redeem_amount(pos.ltoken_balance, state.exchange_rate) } /// Returns a user's current debt in a market in underlying units diff --git a/contracts/tusdt-lending-pool/tests.rs b/contracts/tusdt-lending-pool/tests.rs index e7cb513..548ca19 100644 --- a/contracts/tusdt-lending-pool/tests.rs +++ b/contracts/tusdt-lending-pool/tests.rs @@ -821,6 +821,94 @@ fn exchange_rate_starts_at_one() { assert_eq!(rate, Ratio::one()); } +#[ink::test] +fn mint_amount_scales_by_exchange_rate() { + // Supplying underlying at a grown exchange rate mints proportionally + // fewer lTokens (floored), so a late depositor can never claim interest + // accrued before their deposit (anti-dilution). + let rate = Ratio::from_inner(1_100_000_000_000_000_000); // 1.1 + assert_eq!(compute_mint_amount(100_000_000_000, rate), Some(90_909_090_909)); + // At 1.0 (genesis) minting is 1:1. + assert_eq!(compute_mint_amount(100_000_000_000, Ratio::one()), Some(100_000_000_000)); +} + +#[ink::test] +fn redeem_amount_includes_accrued_interest() { + // Regression: withdrawal redeemed principal only — the exchange rate grew + // but never entered the redeem math, so suppliers could never claim + // borrower interest. Redeem must be ltoken × exchange_rate: 100 lTokens + // at 1.1 redeem 110 TAO — principal plus the accrued share. + let rate = Ratio::from_inner(1_100_000_000_000_000_000); // 1.1 + assert_eq!(compute_redeem_amount(100_000_000_000, rate), Some(110_000_000_000)); + // At 1.0 (no interest accrued) it is exactly the principal. + assert_eq!(compute_redeem_amount(100_000_000_000, Ratio::one()), Some(100_000_000_000)); +} + +#[ink::test] +fn underlying_balance_view_quotes_exchange_rate() { + // The view must quote a position's lTokens at the market exchange rate + // (scaled), not at the principal-only ratio it used before the fix. + let (mut pool, accounts) = setup(); + pool.debug_set_market_state( + 0, + MarketState { + total_supplied: 90_909_090_909, + total_debt: 0, + borrow_index: Ratio::one(), + exchange_rate: Ratio::from_inner(1_100_000_000_000_000_000), + reserve_accrued: 0, + last_update: 0, + }, + ); + pool.debug_set_position( + 0, + accounts.alice, + Position { ltoken_balance: 100_000_000_000, scaled_debt: 0, alpha_principal: 0 }, + ); + assert_eq!(pool.get_underlying_balance(0, accounts.alice), Some(110_000_000_000)); + // Alpha markets (id >= 2) have no lToken: the view stays None. + assert_eq!(pool.get_underlying_balance(2, accounts.alice), None); +} + +#[ink::test] +fn exchange_rate_resets_when_market_fully_drains() { + // After the last supplier withdraws (total_supplied == 0) the exchange + // rate must reset to 1.0 so the next genesis supply mints a 1:1 claim + // that is backed 1:1 — a stale grown rate would over-credit the new + // supplier (their lTokens would claim more than they deposited). + let mut state = MarketState { + total_supplied: 0, + total_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, + last_update: 0, + }; + reset_exchange_rate_when_drained(&mut state); + assert_eq!(state.exchange_rate, Ratio::one()); + // The borrow index is deliberately left alone: it is self-consistent for + // scaled debt, and resetting it could corrupt drifted legacy positions. + assert_eq!(state.borrow_index, Ratio::from_inner(1_050_000_000_000_000_000)); +} + +#[ink::test] +fn exchange_rate_survives_debt_repayment_while_supplied() { + // Utilization dropping to zero must NOT reset the exchange rate while any + // supply remains: the accrued supplier value lives in the grown rate, and + // resetting here would claw back supplier interest (the very bug the + // exchange-rate redemption fix exists to solve). + let mut state = MarketState { + total_supplied: 100_000_000_000, + total_debt: 0, + borrow_index: Ratio::one(), + exchange_rate: Ratio::from_inner(1_100_000_000_000_000_000), + reserve_accrued: 0, + last_update: 0, + }; + reset_exchange_rate_when_drained(&mut state); + assert_eq!(state.exchange_rate, Ratio::from_inner(1_100_000_000_000_000_000)); +} + #[ink::test] fn borrow_index_starts_at_one() { let (pool, _accounts) = setup(); diff --git a/docs/contracts/lending-pool.md b/docs/contracts/lending-pool.md index 3c577e9..b180e46 100644 --- a/docs/contracts/lending-pool.md +++ b/docs/contracts/lending-pool.md @@ -134,11 +134,24 @@ Each supply market has a child ERC-20 receipt token (lTAO, lTUSDT), spawned from rate — your token balance never changes, its worth does: ``` -supply: ltoken_minted = amount · ltoken_total_supply / total_supplied # 1:1 at genesis -withdraw: underlying = ltoken_burned · total_supplied / ltoken_supply +supply: ltoken_minted = amount / exchange_rate # 1:1 at genesis +withdraw: underlying = ltoken_burned · exchange_rate underlying = ltoken_balance · exchange_rate ``` +Borrower interest reaches suppliers through that exchange-rate growth: repaying `1.001` for a +`1` borrow retires the debt, and the `0.001` interest (minus the reserve slice) raises the +redemption value of every lToken. `total_supplied` is tracked in lToken (scaled) units — the +face value owed to suppliers is `total_supplied · exchange_rate` — and supply/withdraw book it +in lTokens minted/burned. Deposits at a grown rate mint proportionally fewer lTokens, so late +suppliers can never claim interest accrued before their deposit. + +The exchange rate **never resets while any supply remains** — a full debt repayment (utilization +→ 0) leaves it grown, preserving the accrued supplier value. Only when the market fully drains +(`total_supplied == 0`, every lToken burned) does it reset to 1.0, so the next genesis supply +mints 1:1 claims that are backed 1:1. The borrow index is never reset: it is self-consistent +for scaled debt across cycles. + Amounts that round to zero revert with `MintBelowPrecision`. ### Alpha collateral value