From 8e92072978044c9863acb8237a133f82d8748812 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Mon, 5 Oct 2026 18:12:05 +0000 Subject: [PATCH] Lending: no account a borrower controls on liquidation; config limits that protect open loans From the book's pre-print verification of the third audit's changes, applied to every lending copy (Anchor 1, Anchor 2, Quasar). Security fix: liquidate_obligation() no longer takes the obligation's owner as a rent destination. That account was a System Program wallet a borrower could reassign to make every liquidation of their position fail, and in two copies it refused self-liquidation as a duplicate account. A liquidation that empties a collateral share vault now closes it with the obligation as the rent destination, and close_obligation() returns that rent to the owner with the obligation's own. Config limits: validate() refuses any borrow rate above BORROW_RATE_CEILING_BPS (300% a year) with BorrowRateAboveCeiling, and a liquidation threshold and bonus whose product exceeds 100% with LiquidationBonusUnpayable, so a liquidation at the threshold can always pay its bonus. update_reserve_config() refuses a lower liquidation threshold (RiskLimitLowered), so no update moves the liquidation line toward an open borrow; the loan-to-value may still be lowered to stop new borrowing. Tests for each path: self-liquidation partial and emptying, a full liquidation that sweeps donated shares, a partial one that keeps the vault open, close_obligation() refused while debt remains and returning both rents after it is repaid, a redeposit after a full liquidation, each rate field alone above the ceiling, and the threshold and bonus bound at, above and below its limit. The order book gains a test closing an order that eviction cancelled, then its owner's market user. Every copy built with platform-tools v1.53 (v1.52 for Quasar) and its suites run. Claude-Session: https://claude.ai/code/session_01UX53A6YR1Hjr8z6WzJxf2q --- finance/lending/anchor-v1/CHANGELOG.md | 52 +- finance/lending/anchor-v1/README.md | 64 ++- .../programs/lending/src/constants.rs | 8 + .../anchor-v1/programs/lending/src/errors.rs | 6 + .../admin/update_reserve_config.rs | 20 + .../src/instructions/close_obligation.rs | 4 +- .../src/instructions/liquidate_obligation.rs | 19 +- .../programs/lending/src/state/reserve.rs | 22 +- .../programs/lending/tests/common/mod.rs | 51 +- .../lending/tests/test_liquidation.rs | 254 ++++++++- .../programs/lending/tests/test_reserve.rs | 281 +++++++++- finance/lending/anchor/CHANGELOG.md | 52 +- finance/lending/anchor/README.md | 64 ++- .../anchor/programs/lending/src/constants.rs | 8 + .../anchor/programs/lending/src/errors.rs | 6 + .../admin/update_reserve_config.rs | 20 + .../src/instructions/close_obligation.rs | 4 +- .../src/instructions/liquidate_obligation.rs | 26 +- .../programs/lending/src/state/reserve.rs | 22 +- .../programs/lending/tests/common/mod.rs | 51 +- .../lending/tests/test_liquidation.rs | 260 +++++++-- .../programs/lending/tests/test_reserve.rs | 246 ++++++++- finance/lending/quasar/CHANGELOG.md | 48 +- finance/lending/quasar/README.md | 51 +- finance/lending/quasar/src/constants.rs | 7 + finance/lending/quasar/src/error.rs | 7 + .../quasar/src/instructions/position.rs | 32 +- finance/lending/quasar/src/math.rs | 22 +- finance/lending/quasar/src/tests.rs | 503 ++++++++++++++++-- .../order-book/tests/test_order_book.rs | 70 +++ .../order-book/tests/test_order_book.rs | 70 +++ finance/order-book/quasar/src/tests.rs | 53 ++ 32 files changed, 2111 insertions(+), 292 deletions(-) diff --git a/finance/lending/anchor-v1/CHANGELOG.md b/finance/lending/anchor-v1/CHANGELOG.md index a064b614..ce8b0cff 100644 --- a/finance/lending/anchor-v1/CHANGELOG.md +++ b/finance/lending/anchor-v1/CHANGELOG.md @@ -2,22 +2,58 @@ ## Unreleased (2026-10-05) +Cap the borrow rate, ratchet the liquidation threshold and keep the bonus +payable. The market owner's `update_reserve_config` acts at once on a reserve +with open loans, and could raise the rate curve to any u16 (655% a year) or +lower `liquidation_threshold_bps` and make existing borrowers liquidatable on +the spot. `ReserveConfig::validate` now refuses any of `min_borrow_rate_bps`, +`optimal_borrow_rate_bps` or `max_borrow_rate_bps` above the new +`BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year) with the new +`BorrowRateAboveCeiling` error, at `initialize_reserve` and on every update. +`update_reserve_config` also refuses a config whose +`liquidation_threshold_bps` is below the reserve's current value with the new +`RiskLimitLowered` error; raising it is allowed. `loan_to_value_bps` is not +ratcheted, because it limits only new borrows, so the owner can still lower it +to stop new borrowing against an asset. Because the threshold can now only +rise, `ReserveConfig::validate` also refuses a config where +`liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds +`10_000 * 10_000` with the new `LiquidationBonusUnpayable` error, so a +liquidation at the threshold can always pay its bonus out of the collateral. +Tested by `rejects_borrow_rate_above_ceiling_at_initialize`, +`rejects_borrow_rate_above_ceiling_on_update` (each rate field alone above the +ceiling), `accepts_borrow_rate_at_ceiling`, +`rejects_lowering_liquidation_threshold`, `accepts_lowering_loan_to_value`, +`accepts_raising_loan_to_value_and_liquidation_threshold`, +`accepts_curve_change_with_risk_limits_unchanged`, +`rejects_unpayable_liquidation_bonus_at_initialize`, +`rejects_unpayable_liquidation_bonus_on_update` and +`accepts_liquidation_bonus_at_the_bound`; `accepts_valid_config_update` now +raises the loan-to-value instead of lowering it. The tests gain a +`try_add_reserve_to` helper that returns the `initialize_reserve` result. + Close each collateral vault when its last share leaves. A withdrawal or a liquidation that empties a reserve's deposit entry now also closes that -reserve's per-obligation share vault, rent to the obligation's owner, who paid -it in `deposit_obligation_collateral` (`init_if_needed` recreates it on a later +reserve's per-obligation share vault, whose rent the obligation's owner paid +in `deposit_obligation_collateral` (`init_if_needed` recreates it on a later deposit). The vault's whole balance moves out first, to the owner on a withdrawal and to the liquidator on a liquidation, so share tokens donated -straight to the vault cannot keep it open or make the withdrawal fail. -`withdraw_obligation_collateral`'s `owner` is now writable, and -`liquidate_obligation` takes a new `obligation_owner` account -(`address = obligation.owner`) to receive the rent. Tested by +straight to the vault cannot keep it open or make the withdrawal fail. A +withdrawal closes the vault to the owner, whose `owner` account is now +writable. A liquidation closes it into the obligation account, and +`close_obligation` returns that rent to the owner with the obligation's own; +`liquidate_obligation`'s accounts are unchanged. Tested by `full_withdraw_closes_the_vault_and_returns_its_rent`, `partial_withdraw_keeps_the_vault_open`, `redeposit_after_full_withdraw_recreates_the_vault`, `donated_shares_cannot_keep_the_vault_open`, -`seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner` -and `liquidator_cannot_redirect_the_vault_rent` (`ConstraintAddress`); +`seizing_all_collateral_closes_the_vault_into_the_obligation`, +`seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`, +`partial_liquidation_keeps_the_vault_open`, +`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`, +`close_obligation_refused_while_debt_remains_after_full_liquidation`, +`redeposit_after_full_liquidation_recreates_the_vault`, +`owner_can_liquidate_their_own_obligation` and +`owner_can_liquidate_their_own_obligation_to_empty`; `debt_free_withdraw_needs_no_price_and_no_refresh` now asserts the vault is gone. diff --git a/finance/lending/anchor-v1/README.md b/finance/lending/anchor-v1/README.md index 472f9e8b..4d31c0a3 100644 --- a/finance/lending/anchor-v1/README.md +++ b/finance/lending/anchor-v1/README.md @@ -136,16 +136,30 @@ Once the collateral is out, `close_obligation` returns the account's rent to the owner; it refuses with `ObligationNotEmpty` while any collateral or debt remains, and only the owner may close it. -Each reserve's collateral vault closes when its last share leaves, whether a -withdrawal or a liquidation takes it, and its rent goes back to the -obligation's owner, who paid it when `deposit_obligation_collateral` created -the vault (`init_if_needed` creates it again on a later deposit). The handler -moves the vault's whole balance out before closing it, so share tokens someone -sent straight to the vault cannot keep it open or block the withdrawal -(`donated_shares_cannot_keep_the_vault_open`). `liquidate_obligation` takes the -owner as `obligation_owner` for the rent and refuses any other account -(`liquidator_cannot_redirect_the_vault_rent`). Every account the program creates -for a borrower therefore closes, with its rent returned. +Each reserve's collateral vault closes when its last share leaves. The rent +was paid by the obligation's owner when `deposit_obligation_collateral` +created the vault (`init_if_needed` creates it again on a later deposit). A +withdrawal that empties the vault returns that rent to the owner straight +away. A liquidation that empties it closes it into the obligation account +instead, so liquidation takes no account the borrower controls +(`seizing_all_collateral_closes_the_vault_into_the_obligation`), and the owner +can still liquidate their own position, partly +(`owner_can_liquidate_their_own_obligation`) or down to an empty vault +(`owner_can_liquidate_their_own_obligation_to_empty`). `close_obligation` later +hands the owner the obligation's own rent and the vault's together +(`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`), +and a later deposit recreates the vault +(`redeposit_after_full_liquidation_recreates_the_vault`). +Either way the whole vault balance moves out before the vault closes, so share +tokens someone sent straight to the vault cannot keep it open or block the +withdrawal (`donated_shares_cannot_keep_the_vault_open`, +`seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`). + +Every account the program creates for a borrower closes, with its rent +returned, once the position is fully unwound. An obligation that a +liquidation leaves holding debt and no collateral is not unwound: +`close_obligation` refuses it until that debt is repaid +(`close_obligation_refused_while_debt_remains_after_full_liquidation`). Every handler that pairs an obligation with a reserve requires both to belong to the same `LendingMarket` (`MarketMismatch` otherwise), so each market is an @@ -219,6 +233,33 @@ can update reserve risk parameters (`update_reserve_config`) and withdraw the program's earned fees (`collect_program_fees`), but has no path to a supplier's deposits or a borrower's collateral: there is no admin escape hatch over user funds. +`update_reserve_config` takes effect at once on a reserve with open loans, so +three limits protect the people already there: + +- **A borrow rate ceiling.** `ReserveConfig::validate` refuses any of + `min_borrow_rate_bps`, `optimal_borrow_rate_bps` or `max_borrow_rate_bps` + above `BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year) with + `BorrowRateAboveCeiling`, at `initialize_reserve` and on every update. Without + it the curve could be set to any u16, up to 655% a year. +- **The liquidation threshold only rises.** `update_reserve_config` refuses a + config whose `liquidation_threshold_bps` is lower than the reserve's current + value with `RiskLimitLowered`, so an update can never move the line an open + borrow is measured against and make it liquidatable on the spot. The + loan-to-value is not ratcheted: it limits only new borrows and withdrawals + by an indebted borrower, so the owner may lower it, down to 0, to stop new + borrowing against an asset that has become dangerous + (`accepts_lowering_loan_to_value`). +- **The bonus is always payable.** `ReserveConfig::validate` refuses a config + where `liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds + `10_000 * 10_000` with `LiquidationBonusUnpayable`, at `initialize_reserve` + and on every update. A position becomes liquidatable once its debt passes the + threshold share of its collateral, and the liquidator takes that debt plus + the bonus in collateral, so the bound keeps a liquidation at the threshold + payable from the collateral rather than leaving the suppliers bad debt. + Because the threshold can never come back down, this also stops a mistaken + raise from locking that loss into the reserve. The default 80% threshold + with a 5% bonus gives 8,000 × 10,500 = 84,000,000, inside the bound. + ### Known limits - **Tokens with transfer fees are not supported.** The program uses @@ -227,9 +268,6 @@ deposits or a borrower's collateral: there is no admin escape hatch over user fu accounting would overstate `available_liquidity`. Production lending programs whitelist mints; a market owner here must only create reserves for tokens without transfer fees. -- **Reserve config changes act immediately.** Lowering a reserve's - `liquidation_threshold_bps` can make existing obligations liquidatable at - once; production governance phases such changes in. - This is an example. Deploying any program that custodies funds calls for a professional security audit first. diff --git a/finance/lending/anchor-v1/programs/lending/src/constants.rs b/finance/lending/anchor-v1/programs/lending/src/constants.rs index 8eb01c96..395ce3ff 100644 --- a/finance/lending/anchor-v1/programs/lending/src/constants.rs +++ b/finance/lending/anchor-v1/programs/lending/src/constants.rs @@ -21,6 +21,14 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// Denominator for every basis-point config value. 100% == 10_000 bps. pub const BPS_DENOMINATOR: u128 = 10_000; +/// Highest annual borrow rate, in basis points, any point on a reserve's rate +/// curve may be set to: 30,000 bps, or 300% a year. `ReserveConfig::validate` +/// refuses a `min_borrow_rate_bps`, `optimal_borrow_rate_bps` or +/// `max_borrow_rate_bps` above it (`BorrowRateAboveCeiling`) at creation and on +/// every update, so the market owner cannot reprice open loans to the 655% a +/// year a bare u16 would allow. +pub const BORROW_RATE_CEILING_BPS: u16 = 30_000; + /// Maximum distinct reserves an obligation may use as collateral, and /// separately as borrows. Bounds the account size and the compute cost of /// refresh_obligation (which iterates every entry). diff --git a/finance/lending/anchor-v1/programs/lending/src/errors.rs b/finance/lending/anchor-v1/programs/lending/src/errors.rs index 412e6805..9008eb2b 100644 --- a/finance/lending/anchor-v1/programs/lending/src/errors.rs +++ b/finance/lending/anchor-v1/programs/lending/src/errors.rs @@ -44,4 +44,10 @@ pub enum LendingError { NothingToCollect, #[msg("Obligation still holds collateral or debt and cannot be closed")] ObligationNotEmpty, + #[msg("Borrow rate is above the program's ceiling of 30,000 bps (300% a year)")] + BorrowRateAboveCeiling, + #[msg("A config update may not lower a reserve's liquidation threshold")] + RiskLimitLowered, + #[msg("Liquidation threshold is too high for the collateral to pay the liquidation bonus")] + LiquidationBonusUnpayable, } diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs index 182221f1..944057eb 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -1,12 +1,32 @@ use anchor_lang::prelude::*; +use crate::errors::LendingError; use crate::state::{LendingMarket, Reserve, ReserveConfig}; +/// Replace a reserve's risk and interest-rate config. Only the market's owner +/// may call it. The new config must pass `ReserveConfig::validate` (which +/// includes the `BORROW_RATE_CEILING_BPS` cap on every rate field and the +/// bound that keeps the liquidation bonus payable at the threshold), and it may +/// not lower `liquidation_threshold_bps` below the reserve's current value +/// (`RiskLimitLowered`), so an update cannot turn an open borrow liquidatable. +/// `loan_to_value_bps` may be lowered, down to 0 to stop new borrowing against +/// the asset: it limits only new borrows and withdrawals by an indebted +/// borrower, never whether an open borrow is liquidatable. pub fn handle_update_reserve_config( context: Context, config: ReserveConfig, ) -> Result<()> { config.validate()?; + // The liquidation threshold only ever rises: lowering it could make an + // open borrow liquidatable the moment the update lands. The loan-to-value + // is not ratcheted, because health is measured against the threshold, so + // lowering it touches no open borrow and is how the owner stops new + // borrowing against an asset that has become dangerous. + let current = &context.accounts.reserve.config; + require!( + config.liquidation_threshold_bps >= current.liquidation_threshold_bps, + LendingError::RiskLimitLowered + ); // Accrue at the old curve first, so the seconds since the last refresh are // charged at the rates that applied to them rather than repriced by the new // ones. diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/close_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/close_obligation.rs index d657ada6..d4c8af9e 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/close_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/close_obligation.rs @@ -13,7 +13,9 @@ use crate::state::Obligation; /// `ObligationNotEmpty`. Only the owner may close it (`has_one = owner`), since /// the rent is theirs and a stranger could otherwise close a position its /// owner means to use again. The account itself closes through Anchor's -/// `close = owner` constraint once the handler returns. +/// `close = owner` constraint once the handler returns, which hands the owner +/// every lamport it holds: its own rent, plus the rent of any collateral vault +/// a liquidation emptied and closed into it. pub fn handle_close_obligation(context: Context) -> Result<()> { let obligation = &context.accounts.obligation; require!( diff --git a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs index b617a75e..cfdeefbd 100644 --- a/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor-v1/programs/lending/src/instructions/liquidate_obligation.rs @@ -23,10 +23,14 @@ use crate::state::{Obligation, PriceFeed, Reserve}; /// liquidator pay full price for less collateral. /// /// A seizure that takes the last share of the collateral reserve removes the -/// deposit entry and closes that reserve's collateral vault, rent to the -/// obligation's owner (`obligation_owner`), who paid it. The whole vault +/// deposit entry and closes that reserve's collateral vault. The whole vault /// balance goes to the liquidator first, so share tokens someone sent straight -/// to the vault cannot keep it open. +/// to the vault cannot keep it open. The vault's rent goes into the obligation +/// account itself, not to the owner's wallet: liquidation then takes no +/// account the borrower controls, so nothing the borrower does to their wallet +/// can make it fail, and the owner can still liquidate their own position. The +/// rent returns to the owner, who paid it, when `close_obligation` closes the +/// obligation with every lamport it holds. /// /// Self-liquidation (the owner liquidating their own position) is not blocked: /// it is only possible while unhealthy and is economically pointless, matching @@ -211,7 +215,7 @@ pub fn handle_liquidate_obligation( .accounts .obligation_collateral_vault .to_account_info(), - destination: context.accounts.obligation_owner.to_account_info(), + destination: context.accounts.obligation.to_account_info(), authority: context.accounts.obligation.to_account_info(), }, &[&seeds], @@ -221,7 +225,7 @@ pub fn handle_liquidate_obligation( Ok(()) } -// Liquidation touches 14 accounts; every Account/InterfaceAccount is boxed so +// Liquidation touches 13 accounts; every Account/InterfaceAccount is boxed so // account deserialization happens on the heap and stays within the BPF stack frame. #[derive(Accounts)] pub struct LiquidateObligation<'info> { @@ -230,11 +234,6 @@ pub struct LiquidateObligation<'info> { pub liquidator: Signer<'info>, - /// The obligation's owner, who paid the collateral vault's rent; receives - /// it back if this seizure empties the vault. - #[account(mut, address = obligation.owner)] - pub obligation_owner: SystemAccount<'info>, - #[account( mut, constraint = repay_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, diff --git a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs index bb2aa242..825a885e 100644 --- a/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/src/state/reserve.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; use crate::constants::{ - BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, SECONDS_PER_YEAR, + BORROW_RATE_CEILING_BPS, BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, + SECONDS_PER_YEAR, }; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -147,6 +148,25 @@ impl ReserveConfig { self.loan_to_value_bps <= self.liquidation_threshold_bps, LendingError::InvalidConfig ); + // A liquidation at the threshold must be able to pay its bonus out of + // the collateral: the debt is at most `threshold` of the collateral's + // value, and the liquidator takes that debt plus the bonus, so + // `threshold * (1 + bonus)` may not exceed 100%. Both fields are at + // most 10,000 here, so the product fits a u128 with room to spare. + require!( + (self.liquidation_threshold_bps as u128) + * (BPS_DENOMINATOR + self.liquidation_bonus_bps as u128) + <= BPS_DENOMINATOR * BPS_DENOMINATOR, + LendingError::LiquidationBonusUnpayable + ); + // No point on the rate curve may exceed the ceiling, so an owner + // cannot reprice open loans to an arbitrary rate. + require!( + self.min_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && self.optimal_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && self.max_borrow_rate_bps <= BORROW_RATE_CEILING_BPS, + LendingError::BorrowRateAboveCeiling + ); require!( self.min_borrow_rate_bps <= self.optimal_borrow_rate_bps && self.optimal_borrow_rate_bps <= self.max_borrow_rate_bps, diff --git a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs index aeb208fa..39f141ee 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/common/mod.rs @@ -182,6 +182,20 @@ impl Env { price_mantissa: i128, config: ReserveConfig, ) -> ReserveHandle { + self.try_add_reserve_to(market_owner, market, decimals, price_mantissa, config) + .unwrap() + } + + /// Initialize a reserve with `config` and return the transaction result, so + /// a test can assert on the error `initialize_reserve` refuses it with. + pub fn try_add_reserve_to( + &mut self, + market_owner: &Keypair, + market: Pubkey, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, + ) -> Result { let env_owner = self.owner.insecure_clone(); let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); self.set_price_for( @@ -218,17 +232,16 @@ impl Env { vec![instruction], &[market_owner], &market_owner.pubkey(), - ) - .unwrap(); + )?; - ReserveHandle { + Ok(ReserveHandle { mint, decimals, reserve, share_mint, liquidity_vault, price_feed, - } + }) } pub fn current_timestamp(&self) -> i64 { @@ -814,33 +827,6 @@ impl Env { repay: &ReserveHandle, collateral: &ReserveHandle, amount: u64, - ) -> Result<(), String> { - self.try_liquidate_with_rent_to( - liquidator, - obligation, - deposit_reserves, - borrow_reserves, - repay, - collateral, - amount, - None, - ) - } - - /// Liquidate, naming `obligation_owner` as the account the collateral - /// vault's rent returns to if the seizure empties it (`None` passes the - /// obligation's real owner, the only account the program accepts). - #[allow(clippy::too_many_arguments)] - pub fn try_liquidate_with_rent_to( - &mut self, - liquidator: &Keypair, - obligation: Pubkey, - deposit_reserves: &[&ReserveHandle], - borrow_reserves: &[&ReserveHandle], - repay: &ReserveHandle, - collateral: &ReserveHandle, - amount: u64, - obligation_owner: Option, ) -> Result<(), String> { let repay_source = ata(&liquidator.pubkey(), &repay.mint); // Create the destination ATA only on the first call, so a test can @@ -856,8 +842,6 @@ impl Env { .unwrap(); } let vault = self.obligation_share_vault(collateral, obligation); - let obligation_owner = - obligation_owner.unwrap_or_else(|| self.obligation(obligation).owner); let mut all: Vec<&ReserveHandle> = deposit_reserves.to_vec(); all.extend_from_slice(borrow_reserves); @@ -872,7 +856,6 @@ impl Env { accounts: lending::accounts::LiquidateObligation { obligation, liquidator: liquidator.pubkey(), - obligation_owner, repay_reserve: repay.reserve, collateral_reserve: collateral.reserve, repay_price_feed: repay.price_feed, diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_liquidation.rs b/finance/lending/anchor-v1/programs/lending/tests/test_liquidation.rs index d3f061e2..3d61f1d7 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_liquidation.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_liquidation.rs @@ -150,19 +150,23 @@ fn over_seizing_liquidation_rejected_smaller_succeeds() { ); } +/// Price at which the capped repayment seizes the whole deposit: at $0.3675 +/// the 1,000 collateral units are worth $367.50, and the close factor caps the +/// repayment at half the $700 debt, $350, whose value plus the 5% bonus is +/// exactly $367.50. +const PRICE_TO_SEIZE_EVERYTHING: i128 = 367_500_000_000_000_000; + /// A seizure that takes every collateral share removes the deposit entry and -/// closes the collateral vault, returning its rent to the obligation's owner, -/// who paid it, not to the liquidator who sent the transaction. -/// -/// At $0.3675 the 1,000 collateral units are worth $367.50, and the close -/// factor caps the repayment at half the $700 debt, $350, whose value plus -/// the 5% bonus is exactly $367.50: the whole deposit. +/// closes the collateral vault. Its rent goes into the obligation account, not +/// to any wallet, so liquidation takes no account the borrower controls; the +/// owner's wallet is untouched until `close_obligation` hands it back. #[test] -fn seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner() { +fn seizing_all_collateral_closes_the_vault_into_the_obligation() { let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); - env.set_price(collateral.mint, 367_500_000_000_000_000); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); let vault = env.obligation_share_vault(&collateral, obligation); let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); let owner_before = env.sol_balance(borrower.pubkey()); env.try_liquidate( @@ -183,22 +187,86 @@ fn seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner() { assert!(env.obligation(obligation).deposits.is_empty()); assert!(!env.account_is_open(vault)); assert_eq!( - env.sol_balance(borrower.pubkey()), - owner_before + vault_rent, - "the vault's rent must return to the owner who paid it" + env.sol_balance(obligation), + obligation_before + vault_rent, + "the vault's rent must land in the obligation" ); + assert_eq!(env.sol_balance(borrower.pubkey()), owner_before); } -/// The vault's rent belongs to the owner who paid it, so a liquidator cannot -/// name themself as `obligation_owner` to take it; `address = -/// obligation.owner` refuses before anything moves. +/// Share tokens sent straight to the vault are not recorded in the +/// obligation. A seizure that empties the vault sweeps them to the liquidator +/// with the seized shares, so the vault can still close. #[test] -fn liquidator_cannot_redirect_the_vault_rent() { +fn seizing_all_collateral_sweeps_donated_shares_to_the_liquidator() { let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); - env.set_price(collateral.mint, 367_500_000_000_000_000); let vault = env.obligation_share_vault(&collateral, obligation); + let donor = env.create_user(); + env.fund(&donor, collateral.mint, 5_000_000); + env.supply(&donor, &collateral, 5_000_000); + env.send_shares(&donor, &collateral, vault, 5_000_000); + assert_eq!(env.token_balance(vault), 1_005_000_000); + + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + + assert_eq!( + env.token_balance(ata(&liquidator.pubkey(), &collateral.share_mint)), + 1_005_000_000 + ); + assert!(!env.account_is_open(vault)); + assert_eq!(env.sol_balance(obligation), obligation_before + vault_rent); +} + +/// A seizure that leaves shares behind leaves the vault open, holding exactly +/// the shares the obligation still records. +#[test] +fn partial_liquidation_keeps_the_vault_open() { + let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, cents(80)); + let vault = env.obligation_share_vault(&collateral, obligation); + + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ) + .unwrap(); + + let seized = env.token_balance(ata(&liquidator.pubkey(), &collateral.share_mint)); + assert!(seized > 0); + assert!(env.account_is_open(vault)); + let remaining = env.obligation(obligation).deposits[0].deposited_shares; + assert_eq!(remaining, 1_000_000_000 - seized); + assert_eq!(env.token_balance(vault), remaining); +} - let result = env.try_liquidate_with_rent_to( +/// The vault rent a liquidation left in the obligation returns to the owner +/// when they close it: once the remaining debt is repaid, `close_obligation` +/// pays out the obligation's own rent and the vault's together. +#[test] +fn close_obligation_after_full_liquidation_returns_both_rents_to_the_owner() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_rent = env.sol_balance(obligation); + env.try_liquidate( &liquidator, obligation, &[&collateral], @@ -206,12 +274,156 @@ fn liquidator_cannot_redirect_the_vault_rent() { &borrow, &collateral, 350_000_000, - Some(liquidator.pubkey()), + ) + .unwrap(); + + // The seizure repaid $350 of the $700; the borrower repays the rest. + env.repay(&borrower, obligation, &borrow, 1_000_000_000); + assert!(env.obligation(obligation).borrows.is_empty()); + assert_eq!(env.sol_balance(obligation), obligation_rent + vault_rent); + + let fee = env.transaction_fee(&borrower); + let owner_before = env.sol_balance(borrower.pubkey()); + env.try_close_obligation(&borrower, obligation).unwrap(); + assert!(!env.account_is_open(obligation)); + assert_eq!( + env.sol_balance(borrower.pubkey()), + owner_before + obligation_rent + vault_rent - fee, + "both rents must return to the owner" + ); +} + +/// An owner may liquidate their own unhealthy position: they repay their own +/// debt and take their own collateral at the bonus. Pointless economically, +/// but not refused. +#[test] +fn owner_can_liquidate_their_own_obligation() { + let (mut env, collateral, borrow, borrower, obligation, _liquidator) = setup(); + env.set_price(collateral.mint, cents(80)); + let owner_borrow_tokens = ata(&borrower.pubkey(), &borrow.mint); + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + assert_eq!(env.token_balance(owner_borrow_tokens), 700_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + + env.try_liquidate( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ) + .unwrap(); + + assert_eq!(env.token_balance(owner_borrow_tokens), 600_000_000); + let seized = env.token_balance(owner_shares); + assert!(seized > 0); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 1_000_000_000 - seized ); +} + +/// An owner may also liquidate their own position down to nothing: the +/// seizure empties the vault, which closes into the obligation account, while +/// the owner signs as the liquidator. The owner gets every share back and the +/// vault's rent waits in the obligation. +#[test] +fn owner_can_liquidate_their_own_obligation_to_empty() { + let (mut env, collateral, borrow, borrower, obligation, _liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); + let owner_borrow_tokens = ata(&borrower.pubkey(), &borrow.mint); + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + assert_eq!(env.token_balance(owner_borrow_tokens), 700_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + + env.try_liquidate( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + + assert_eq!(env.token_balance(owner_borrow_tokens), 350_000_000); + assert_eq!(env.token_balance(owner_shares), 1_000_000_000); + assert!(env.obligation(obligation).deposits.is_empty()); + assert!(!env.account_is_open(vault)); + assert_eq!(env.sol_balance(obligation), obligation_before + vault_rent); +} + +/// A liquidation that takes all the collateral and half the debt leaves the +/// obligation holding debt and nothing else, and `close_obligation` refuses it +/// until that debt is repaid. +#[test] +fn close_obligation_refused_while_debt_remains_after_full_liquidation() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + let state = env.obligation(obligation); + assert!(state.deposits.is_empty()); + assert_eq!(state.borrows.len(), 1); + + let result = env.try_close_obligation(&borrower, obligation); assert!( - result.unwrap_err().contains("ConstraintAddress"), - "the vault's rent may only go to the obligation's owner" + result + .unwrap_err() + .contains("Error Code: ObligationNotEmpty."), + "expected ObligationNotEmpty" ); + assert!(env.account_is_open(obligation)); +} + +/// After a liquidation closes the vault, the owner can post collateral again: +/// the deposit recreates the vault, paid for by the owner, and the old vault's +/// rent stays in the obligation. +#[test] +fn redeposit_after_full_liquidation_recreates_the_vault() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_rent = env.sol_balance(obligation); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + assert!(!env.account_is_open(vault)); + + // The owner has no shares left, so the liquidator hands 600 back. + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + env.send_shares(&liquidator, &collateral, owner_shares, 600_000_000); + env.post_collateral(&borrower, obligation, &collateral, 600_000_000); + assert!(env.account_is_open(vault)); - assert_eq!(env.token_balance(vault), 1_000_000_000); + assert_eq!(env.sol_balance(vault), vault_rent); + assert_eq!(env.token_balance(vault), 600_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 600_000_000 + ); + assert_eq!(env.sol_balance(obligation), obligation_rent + vault_rent); } diff --git a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs index cd6e9cb4..01d5a6dc 100644 --- a/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor-v1/programs/lending/tests/test_reserve.rs @@ -1,7 +1,7 @@ mod common; use common::{default_config, Env}; -use lending::constants::FIXED_POINT_SCALE; +use lending::constants::{BORROW_RATE_CEILING_BPS, FIXED_POINT_SCALE}; use lending::state::Reserve; #[test] @@ -96,9 +96,9 @@ fn accepts_valid_config_update() { let usdc = env.add_reserve(6, common::dollars(1), default_config()); let mut updated = default_config(); - updated.loan_to_value_bps = 6_000; + updated.loan_to_value_bps = 7_800; env.try_update_config(&usdc, updated).unwrap(); - assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000); + assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 7_800); } /// A reserve at 50% utilization with a borrower drawing half the pool, so the @@ -222,3 +222,278 @@ fn a_config_update_accrues_at_the_old_rates_first() { factor_after(&before, seconds as u128) ); } + +/// A config whose rate curve is `min`/`optimal`/`max`, every other field the +/// default. +fn config_with_curve(min: u16, optimal: u16, max: u16) -> lending::state::ReserveConfig { + let mut config = default_config(); + config.min_borrow_rate_bps = min; + config.optimal_borrow_rate_bps = optimal; + config.max_borrow_rate_bps = max; + config +} + +/// A reserve cannot be created with a rate above the 300% a year ceiling. +#[test] +fn rejects_borrow_rate_above_ceiling_at_initialize() { + let mut env = Env::new(); + let owner = env.owner.insecure_clone(); + let market = env.market; + let above = config_with_curve(200, 2_000, BORROW_RATE_CEILING_BPS + 1); + let result = env.try_add_reserve_to(&owner, market, 6, common::dollars(1), above); + assert!( + result + .map(|_| ()) + .unwrap_err() + .contains("Error Code: BorrowRateAboveCeiling."), + "expected BorrowRateAboveCeiling" + ); +} + +/// No rate field may be raised above the ceiling on a live reserve. Each of +/// `min`, `optimal` and `max` is tried one past it on its own, with the other +/// two inside the ceiling. For `min` and `optimal` the curve is then misordered +/// as well, and the ceiling check runs first, so each case returns +/// `BorrowRateAboveCeiling` only through its own field's clause. +#[test] +fn rejects_borrow_rate_above_ceiling_on_update() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let above = BORROW_RATE_CEILING_BPS + 1; + for curve in [ + (above, 2_000, 15_000), + (200, above, 15_000), + (200, 2_000, above), + ] { + let result = env.try_update_config(&borrow, config_with_curve(curve.0, curve.1, curve.2)); + assert!( + result + .unwrap_err() + .contains("Error Code: BorrowRateAboveCeiling."), + "expected BorrowRateAboveCeiling for {curve:?}" + ); + } + let config = env.reserve(&borrow).config; + assert_eq!(config.min_borrow_rate_bps, 200); + assert_eq!(config.optimal_borrow_rate_bps, 2_000); + assert_eq!(config.max_borrow_rate_bps, 15_000); +} + +/// A rate exactly at the ceiling is accepted, at creation and on update. +#[test] +fn accepts_borrow_rate_at_ceiling() { + let mut env = Env::new(); + let at_ceiling = config_with_curve(200, 2_000, BORROW_RATE_CEILING_BPS); + let usdc = env.add_reserve(6, common::dollars(1), at_ceiling); + assert_eq!( + env.reserve(&usdc).config.max_borrow_rate_bps, + BORROW_RATE_CEILING_BPS + ); + + let flat_at_ceiling = config_with_curve( + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + ); + env.try_update_config(&usdc, flat_at_ceiling).unwrap(); + let config = env.reserve(&usdc).config; + assert_eq!(config.min_borrow_rate_bps, BORROW_RATE_CEILING_BPS); + assert_eq!(config.optimal_borrow_rate_bps, BORROW_RATE_CEILING_BPS); + assert_eq!(config.max_borrow_rate_bps, BORROW_RATE_CEILING_BPS); +} + +/// Lowering the liquidation threshold of a reserve backing an open borrow is +/// refused: it would move the line the borrower is measured against. +#[test] +fn rejects_lowering_liquidation_threshold() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + let mut lower = default_config(); + lower.liquidation_threshold_bps = 7_900; + let result = env.try_update_config(&collateral, lower); + assert!( + result + .unwrap_err() + .contains("Error Code: RiskLimitLowered."), + "expected RiskLimitLowered" + ); + assert_eq!( + env.reserve(&collateral).config.liquidation_threshold_bps, + 8_000 + ); +} + +/// Lowering the loan-to-value is accepted, down to 0: it limits only new +/// borrows, so the open borrow stays healthy and cannot be liquidated, while a +/// new borrow past the lower limit is refused. +#[test] +fn accepts_lowering_loan_to_value() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + let borrow = env.add_reserve(6, common::dollars(1), default_config()); + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + // $700 against $1,000 of collateral: inside the 75% loan-to-value. + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 700_000_000, + ) + .unwrap(); + + let mut frozen = default_config(); + frozen.loan_to_value_bps = 0; + env.try_update_config(&collateral, frozen).unwrap(); + assert_eq!(env.reserve(&collateral).config.loan_to_value_bps, 0); + + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + 1, + ); + assert!( + result.unwrap_err().contains("Error Code: BorrowTooLarge."), + "expected BorrowTooLarge" + ); + + let liquidator = env.create_user(); + env.fund(&liquidator, borrow.mint, 1_000_000_000); + let result = env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ); + assert!( + result + .unwrap_err() + .contains("Error Code: ObligationHealthy."), + "expected ObligationHealthy" + ); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 1_000_000_000 + ); +} + +/// Raising both limits loosens the reserve for every borrower and is accepted. +#[test] +fn accepts_raising_loan_to_value_and_liquidation_threshold() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + let mut higher = default_config(); + higher.loan_to_value_bps = 7_600; + higher.liquidation_threshold_bps = 8_100; + env.try_update_config(&collateral, higher).unwrap(); + let config = env.reserve(&collateral).config; + assert_eq!(config.loan_to_value_bps, 7_600); + assert_eq!(config.liquidation_threshold_bps, 8_100); +} + +/// An update that leaves both limits where they are and moves the rate curve +/// within the ceiling is accepted. +#[test] +fn accepts_curve_change_with_risk_limits_unchanged() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let steeper = config_with_curve(500, 5_000, BORROW_RATE_CEILING_BPS); + env.try_update_config(&borrow, steeper).unwrap(); + let config = env.reserve(&borrow).config; + assert_eq!(config.loan_to_value_bps, 7_500); + assert_eq!(config.liquidation_threshold_bps, 8_000); + assert_eq!(config.min_borrow_rate_bps, 500); + assert_eq!(config.optimal_borrow_rate_bps, 5_000); + assert_eq!(config.max_borrow_rate_bps, BORROW_RATE_CEILING_BPS); +} + +/// A config whose liquidation threshold and bonus are `threshold` and +/// `bonus`, every other field the default. +fn config_with_threshold_and_bonus(threshold: u16, bonus: u16) -> lending::state::ReserveConfig { + let mut config = default_config(); + config.liquidation_threshold_bps = threshold; + config.liquidation_bonus_bps = bonus; + config +} + +/// A reserve cannot be created with a threshold so high that a liquidation at +/// it could not pay the bonus from the collateral: 8,000 x 12,501 is past +/// 10,000 x 10,000. +#[test] +fn rejects_unpayable_liquidation_bonus_at_initialize() { + let mut env = Env::new(); + let owner = env.owner.insecure_clone(); + let market = env.market; + let unpayable = config_with_threshold_and_bonus(8_000, 2_501); + let result = env.try_add_reserve_to(&owner, market, 6, common::dollars(1), unpayable); + assert!( + result + .map(|_| ()) + .unwrap_err() + .contains("Error Code: LiquidationBonusUnpayable."), + "expected LiquidationBonusUnpayable" + ); +} + +/// An update may not cross the bound either, by raising the bonus or by +/// raising the threshold, and the config is left as it was. +#[test] +fn rejects_unpayable_liquidation_bonus_on_update() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + // 8,000 x 12,501 and 9,524 x 10,500 are each past 100,000,000. + for (threshold, bonus) in [(8_000, 2_501), (9_524, 500)] { + let result = env.try_update_config( + &collateral, + config_with_threshold_and_bonus(threshold, bonus), + ); + assert!( + result + .unwrap_err() + .contains("Error Code: LiquidationBonusUnpayable."), + "expected LiquidationBonusUnpayable for ({threshold}, {bonus})" + ); + } + let config = env.reserve(&collateral).config; + assert_eq!(config.liquidation_threshold_bps, 8_000); + assert_eq!(config.liquidation_bonus_bps, 500); +} + +/// A threshold and bonus exactly at the bound are accepted, at creation and on +/// update: 8,000 x 12,500 is exactly 100,000,000, and 9,523 is the highest +/// threshold a 5% bonus allows (9,523 x 10,500 is 99,991,500). +#[test] +fn accepts_liquidation_bonus_at_the_bound() { + let mut env = Env::new(); + let usdc = env.add_reserve( + 6, + common::dollars(1), + config_with_threshold_and_bonus(8_000, 2_500), + ); + assert_eq!(env.reserve(&usdc).config.liquidation_bonus_bps, 2_500); + + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + env.try_update_config(&collateral, config_with_threshold_and_bonus(8_000, 2_500)) + .unwrap(); + assert_eq!(env.reserve(&collateral).config.liquidation_bonus_bps, 2_500); + env.try_update_config(&collateral, config_with_threshold_and_bonus(9_523, 500)) + .unwrap(); + let config = env.reserve(&collateral).config; + assert_eq!(config.liquidation_threshold_bps, 9_523); + assert_eq!(config.liquidation_bonus_bps, 500); +} diff --git a/finance/lending/anchor/CHANGELOG.md b/finance/lending/anchor/CHANGELOG.md index 02d1261e..e40d3167 100644 --- a/finance/lending/anchor/CHANGELOG.md +++ b/finance/lending/anchor/CHANGELOG.md @@ -2,22 +2,58 @@ ## Unreleased (2026-10-05) +Cap the borrow rate, ratchet the liquidation threshold and keep the bonus +payable. The market owner's `update_reserve_config` acts at once on a reserve +with open loans, and could raise the rate curve to any u16 (655% a year) or +lower `liquidation_threshold_bps` and make existing borrowers liquidatable on +the spot. `ReserveConfig::validate` now refuses any of `min_borrow_rate_bps`, +`optimal_borrow_rate_bps` or `max_borrow_rate_bps` above the new +`BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year) with the new +`BorrowRateAboveCeiling` error, at `initialize_reserve` and on every update. +`update_reserve_config` also refuses a config whose +`liquidation_threshold_bps` is below the reserve's current value with the new +`RiskLimitLowered` error; raising it is allowed. `loan_to_value_bps` is not +ratcheted, because it limits only new borrows, so the owner can still lower it +to stop new borrowing against an asset. Because the threshold can now only +rise, `ReserveConfig::validate` also refuses a config where +`liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds +`10_000 * 10_000` with the new `LiquidationBonusUnpayable` error, so a +liquidation at the threshold can always pay its bonus out of the collateral. +Tested by `rejects_borrow_rate_above_ceiling_at_initialize`, +`rejects_borrow_rate_above_ceiling_on_update` (each rate field alone above the +ceiling), `accepts_borrow_rate_at_ceiling`, +`rejects_lowering_liquidation_threshold`, `accepts_lowering_loan_to_value`, +`accepts_raising_loan_to_value_and_liquidation_threshold`, +`accepts_curve_change_with_risk_limits_unchanged`, +`rejects_unpayable_liquidation_bonus_at_initialize`, +`rejects_unpayable_liquidation_bonus_on_update` and +`accepts_liquidation_bonus_at_the_bound`; `accepts_valid_config_update` now +raises the loan-to-value instead of lowering it. The tests gain a +`try_add_reserve_to` helper that returns the `initialize_reserve` result. + Close each collateral vault when its last share leaves. A withdrawal or a liquidation that empties a reserve's deposit entry now also closes that -reserve's per-obligation share vault, rent to the obligation's owner, who paid -it in `deposit_obligation_collateral` (`init_if_needed` recreates it on a later +reserve's per-obligation share vault, whose rent the obligation's owner paid +in `deposit_obligation_collateral` (`init_if_needed` recreates it on a later deposit). The vault's whole balance moves out first, to the owner on a withdrawal and to the liquidator on a liquidation, so share tokens donated -straight to the vault cannot keep it open or make the withdrawal fail. -`withdraw_obligation_collateral`'s `owner` is now writable, and -`liquidate_obligation` takes a new `obligation_owner` account -(`address = obligation.owner`) to receive the rent. Tested by +straight to the vault cannot keep it open or make the withdrawal fail. A +withdrawal closes the vault to the owner, whose `owner` account is now +writable. A liquidation closes it into the obligation account, and +`close_obligation` returns that rent to the owner with the obligation's own; +`liquidate_obligation`'s accounts are unchanged. Tested by `full_withdraw_closes_the_vault_and_returns_its_rent`, `partial_withdraw_keeps_the_vault_open`, `redeposit_after_full_withdraw_recreates_the_vault`, `donated_shares_cannot_keep_the_vault_open`, -`seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner` -and `liquidator_cannot_redirect_the_vault_rent` (`ConstraintAddress`); +`seizing_all_collateral_closes_the_vault_into_the_obligation`, +`seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`, +`partial_liquidation_keeps_the_vault_open`, +`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`, +`close_obligation_refused_while_debt_remains_after_full_liquidation`, +`redeposit_after_full_liquidation_recreates_the_vault`, +`owner_can_liquidate_their_own_obligation` and +`owner_can_liquidate_their_own_obligation_to_empty`; `debt_free_withdraw_needs_no_price_and_no_refresh` now asserts the vault is gone. diff --git a/finance/lending/anchor/README.md b/finance/lending/anchor/README.md index f3f2f892..4c21ac9a 100644 --- a/finance/lending/anchor/README.md +++ b/finance/lending/anchor/README.md @@ -136,16 +136,30 @@ Once the collateral is out, `close_obligation` returns the account's rent to the owner; it refuses with `ObligationNotEmpty` while any collateral or debt remains, and only the owner may close it. -Each reserve's collateral vault closes when its last share leaves, whether a -withdrawal or a liquidation takes it, and its rent goes back to the -obligation's owner, who paid it when `deposit_obligation_collateral` created -the vault (`init_if_needed` creates it again on a later deposit). The handler -moves the vault's whole balance out before closing it, so share tokens someone -sent straight to the vault cannot keep it open or block the withdrawal -(`donated_shares_cannot_keep_the_vault_open`). `liquidate_obligation` takes the -owner as `obligation_owner` for the rent and refuses any other account -(`liquidator_cannot_redirect_the_vault_rent`). Every account the program creates -for a borrower therefore closes, with its rent returned. +Each reserve's collateral vault closes when its last share leaves. The rent +was paid by the obligation's owner when `deposit_obligation_collateral` +created the vault (`init_if_needed` creates it again on a later deposit). A +withdrawal that empties the vault returns that rent to the owner straight +away. A liquidation that empties it closes it into the obligation account +instead, so liquidation takes no account the borrower controls +(`seizing_all_collateral_closes_the_vault_into_the_obligation`), and the owner +can still liquidate their own position, partly +(`owner_can_liquidate_their_own_obligation`) or down to an empty vault +(`owner_can_liquidate_their_own_obligation_to_empty`). `close_obligation` later +hands the owner the obligation's own rent and the vault's together +(`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`), +and a later deposit recreates the vault +(`redeposit_after_full_liquidation_recreates_the_vault`). +Either way the whole vault balance moves out before the vault closes, so share +tokens someone sent straight to the vault cannot keep it open or block the +withdrawal (`donated_shares_cannot_keep_the_vault_open`, +`seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`). + +Every account the program creates for a borrower closes, with its rent +returned, once the position is fully unwound. An obligation that a +liquidation leaves holding debt and no collateral is not unwound: +`close_obligation` refuses it until that debt is repaid +(`close_obligation_refused_while_debt_remains_after_full_liquidation`). Every handler that pairs an obligation with a reserve requires both to belong to the same `LendingMarket` (`MarketMismatch` otherwise), so each market is an @@ -219,6 +233,33 @@ can update reserve risk parameters (`update_reserve_config`) and withdraw the program's earned fees (`collect_program_fees`), but has no path to a supplier's deposits or a borrower's collateral: there is no admin escape hatch over user funds. +`update_reserve_config` takes effect at once on a reserve with open loans, so +three limits protect the people already there: + +- **A borrow rate ceiling.** `ReserveConfig::validate` refuses any of + `min_borrow_rate_bps`, `optimal_borrow_rate_bps` or `max_borrow_rate_bps` + above `BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year) with + `BorrowRateAboveCeiling`, at `initialize_reserve` and on every update. Without + it the curve could be set to any u16, up to 655% a year. +- **The liquidation threshold only rises.** `update_reserve_config` refuses a + config whose `liquidation_threshold_bps` is lower than the reserve's current + value with `RiskLimitLowered`, so an update can never move the line an open + borrow is measured against and make it liquidatable on the spot. The + loan-to-value is not ratcheted: it limits only new borrows and withdrawals + by an indebted borrower, so the owner may lower it, down to 0, to stop new + borrowing against an asset that has become dangerous + (`accepts_lowering_loan_to_value`). +- **The bonus is always payable.** `ReserveConfig::validate` refuses a config + where `liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds + `10_000 * 10_000` with `LiquidationBonusUnpayable`, at `initialize_reserve` + and on every update. A position becomes liquidatable once its debt passes the + threshold share of its collateral, and the liquidator takes that debt plus + the bonus in collateral, so the bound keeps a liquidation at the threshold + payable from the collateral rather than leaving the suppliers bad debt. + Because the threshold can never come back down, this also stops a mistaken + raise from locking that loss into the reserve. The default 80% threshold + with a 5% bonus gives 8,000 × 10,500 = 84,000,000, inside the bound. + ### Known limits - **Tokens with transfer fees are not supported.** The program uses @@ -227,9 +268,6 @@ deposits or a borrower's collateral: there is no admin escape hatch over user fu accounting would overstate `available_liquidity`. Production lending programs whitelist mints; a market owner here must only create reserves for tokens without transfer fees. -- **Reserve config changes act immediately.** Lowering a reserve's - `liquidation_threshold_bps` can make existing obligations liquidatable at - once; production governance phases such changes in. - This is an example. Deploying any program that custodies funds calls for a professional security audit first. diff --git a/finance/lending/anchor/programs/lending/src/constants.rs b/finance/lending/anchor/programs/lending/src/constants.rs index 8eb01c96..395ce3ff 100644 --- a/finance/lending/anchor/programs/lending/src/constants.rs +++ b/finance/lending/anchor/programs/lending/src/constants.rs @@ -21,6 +21,14 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// Denominator for every basis-point config value. 100% == 10_000 bps. pub const BPS_DENOMINATOR: u128 = 10_000; +/// Highest annual borrow rate, in basis points, any point on a reserve's rate +/// curve may be set to: 30,000 bps, or 300% a year. `ReserveConfig::validate` +/// refuses a `min_borrow_rate_bps`, `optimal_borrow_rate_bps` or +/// `max_borrow_rate_bps` above it (`BorrowRateAboveCeiling`) at creation and on +/// every update, so the market owner cannot reprice open loans to the 655% a +/// year a bare u16 would allow. +pub const BORROW_RATE_CEILING_BPS: u16 = 30_000; + /// Maximum distinct reserves an obligation may use as collateral, and /// separately as borrows. Bounds the account size and the compute cost of /// refresh_obligation (which iterates every entry). diff --git a/finance/lending/anchor/programs/lending/src/errors.rs b/finance/lending/anchor/programs/lending/src/errors.rs index 412e6805..9008eb2b 100644 --- a/finance/lending/anchor/programs/lending/src/errors.rs +++ b/finance/lending/anchor/programs/lending/src/errors.rs @@ -44,4 +44,10 @@ pub enum LendingError { NothingToCollect, #[msg("Obligation still holds collateral or debt and cannot be closed")] ObligationNotEmpty, + #[msg("Borrow rate is above the program's ceiling of 30,000 bps (300% a year)")] + BorrowRateAboveCeiling, + #[msg("A config update may not lower a reserve's liquidation threshold")] + RiskLimitLowered, + #[msg("Liquidation threshold is too high for the collateral to pay the liquidation bonus")] + LiquidationBonusUnpayable, } diff --git a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs index 89f13a2a..f1700995 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/admin/update_reserve_config.rs @@ -1,12 +1,32 @@ use anchor_lang::prelude::*; +use crate::errors::LendingError; use crate::state::{LendingMarket, Reserve, ReserveConfig}; +/// Replace a reserve's risk and interest-rate config. Only the market's owner +/// may call it. The new config must pass `ReserveConfig::validate` (which +/// includes the `BORROW_RATE_CEILING_BPS` cap on every rate field and the +/// bound that keeps the liquidation bonus payable at the threshold), and it may +/// not lower `liquidation_threshold_bps` below the reserve's current value +/// (`RiskLimitLowered`), so an update cannot turn an open borrow liquidatable. +/// `loan_to_value_bps` may be lowered, down to 0 to stop new borrowing against +/// the asset: it limits only new borrows and withdrawals by an indebted +/// borrower, never whether an open borrow is liquidatable. pub fn handle_update_reserve_config( context: &mut Context, config: ReserveConfig, ) -> Result<()> { config.validate()?; + // The liquidation threshold only ever rises: lowering it could make an + // open borrow liquidatable the moment the update lands. The loan-to-value + // is not ratcheted, because health is measured against the threshold, so + // lowering it touches no open borrow and is how the owner stops new + // borrowing against an asset that has become dangerous. + let current = &context.accounts.reserve.config; + require!( + config.liquidation_threshold_bps >= current.liquidation_threshold_bps, + LendingError::RiskLimitLowered + ); // Accrue at the old curve first, so the seconds since the last refresh are // charged at the rates that applied to them rather than repriced by the new // ones. diff --git a/finance/lending/anchor/programs/lending/src/instructions/close_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/close_obligation.rs index 3a18b4f8..91bf9dee 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/close_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/close_obligation.rs @@ -13,7 +13,9 @@ use crate::state::Obligation; /// `ObligationNotEmpty`. Only the owner may close it (`address = /// obligation.owner`), since the rent is theirs and a stranger could otherwise /// close a position its owner means to use again. The account itself closes -/// through Anchor's `close = owner` constraint once the handler returns. +/// through Anchor's `close = owner` constraint once the handler returns, which +/// hands the owner every lamport it holds: its own rent, plus the rent of any +/// collateral vault a liquidation emptied and closed into it. pub fn handle_close_obligation(context: &mut Context) -> Result<()> { let obligation = &context.accounts.obligation; require!( diff --git a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs index 3421fb91..ecbaef66 100644 --- a/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs +++ b/finance/lending/anchor/programs/lending/src/instructions/liquidate_obligation.rs @@ -24,10 +24,14 @@ use crate::state::{Obligation, PriceFeed, Reserve}; /// liquidator pay full price for less collateral. /// /// A seizure that takes the last share of the collateral reserve removes the -/// deposit entry and closes that reserve's collateral vault, rent to the -/// obligation's owner (`obligation_owner`), who paid it. The whole vault +/// deposit entry and closes that reserve's collateral vault. The whole vault /// balance goes to the liquidator first, so share tokens someone sent straight -/// to the vault cannot keep it open. +/// to the vault cannot keep it open. The vault's rent goes into the obligation +/// account itself, not to the owner's wallet: liquidation then takes no +/// account the borrower controls, so nothing the borrower does to their wallet +/// can make it fail, and the owner can still liquidate their own position. The +/// rent returns to the owner, who paid it, when `close_obligation` closes the +/// obligation with every lamport it holds. /// /// Self-liquidation (the owner liquidating their own position) is not blocked: /// it is only possible while unhealthy and is economically pointless, matching @@ -214,6 +218,11 @@ pub fn handle_liquidate_obligation( context.accounts.collateral_share_mint.decimals(), )?; if empties_vault { + // The obligation is both the vault's authority and the rent's + // destination, so the two handles come from separate copies of its + // view rather than two borrows of the same field. + let mut destination_view = *context.accounts.obligation.account(); + let authority_view = *context.accounts.obligation.account(); close_account(CpiContext::new_with_signer( context.accounts.token_program.address(), CloseAccount { @@ -221,8 +230,8 @@ pub fn handle_liquidate_obligation( .accounts .obligation_collateral_vault .to_cpi_handle_mut(), - destination: context.accounts.obligation_owner.cpi_handle_mut(), - authority: context.accounts.obligation.cpi_handle(), + destination: CpiHandleMut::writable(&mut destination_view), + authority: CpiHandle::readonly(&authority_view), }, &[&seeds], ))?; @@ -232,7 +241,7 @@ pub fn handle_liquidate_obligation( Ok(()) } -// Liquidation touches 14 accounts; every Account/InterfaceAccount is boxed so +// Liquidation touches 13 accounts; every Account/InterfaceAccount is boxed so // account deserialization happens on the heap and stays within the BPF stack frame. #[derive(Accounts)] pub struct LiquidateObligation { @@ -241,11 +250,6 @@ pub struct LiquidateObligation { pub liquidator: Signer, - /// The obligation's owner, who paid the collateral vault's rent; receives - /// it back if this seizure empties the vault. - #[account(mut, address = obligation.owner)] - pub obligation_owner: SystemAccount, - #[account( mut, constraint = repay_reserve.lending_market == obligation.lending_market @ LendingError::MarketMismatch, diff --git a/finance/lending/anchor/programs/lending/src/state/reserve.rs b/finance/lending/anchor/programs/lending/src/state/reserve.rs index 4212af24..4f480167 100644 --- a/finance/lending/anchor/programs/lending/src/state/reserve.rs +++ b/finance/lending/anchor/programs/lending/src/state/reserve.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; use crate::constants::{ - BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, SECONDS_PER_YEAR, + BORROW_RATE_CEILING_BPS, BPS_DENOMINATOR, FIXED_POINT_SCALE, MINIMUM_SHARES, RESERVE_SEED, + SECONDS_PER_YEAR, }; use crate::errors::LendingError; use crate::math::{mul_div_ceil, mul_div_floor}; @@ -149,6 +150,25 @@ impl ReserveConfig { self.loan_to_value_bps <= self.liquidation_threshold_bps, LendingError::InvalidConfig ); + // A liquidation at the threshold must be able to pay its bonus out of + // the collateral: the debt is at most `threshold` of the collateral's + // value, and the liquidator takes that debt plus the bonus, so + // `threshold * (1 + bonus)` may not exceed 100%. Both fields are at + // most 10,000 here, so the product fits a u128 with room to spare. + require!( + (self.liquidation_threshold_bps as u128) + * (BPS_DENOMINATOR + self.liquidation_bonus_bps as u128) + <= BPS_DENOMINATOR * BPS_DENOMINATOR, + LendingError::LiquidationBonusUnpayable + ); + // No point on the rate curve may exceed the ceiling, so an owner + // cannot reprice open loans to an arbitrary rate. + require!( + self.min_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && self.optimal_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && self.max_borrow_rate_bps <= BORROW_RATE_CEILING_BPS, + LendingError::BorrowRateAboveCeiling + ); require!( self.min_borrow_rate_bps <= self.optimal_borrow_rate_bps && self.optimal_borrow_rate_bps <= self.max_borrow_rate_bps, diff --git a/finance/lending/anchor/programs/lending/tests/common/mod.rs b/finance/lending/anchor/programs/lending/tests/common/mod.rs index 806759b4..0c1824bc 100644 --- a/finance/lending/anchor/programs/lending/tests/common/mod.rs +++ b/finance/lending/anchor/programs/lending/tests/common/mod.rs @@ -197,6 +197,20 @@ impl Env { price_mantissa: i128, config: ReserveConfig, ) -> ReserveHandle { + self.try_add_reserve_to(market_owner, market, decimals, price_mantissa, config) + .unwrap() + } + + /// Initialize a reserve with `config` and return the transaction result, so + /// a test can assert on the error `initialize_reserve` refuses it with. + pub fn try_add_reserve_to( + &mut self, + market_owner: &Keypair, + market: Address, + decimals: u8, + price_mantissa: i128, + config: ReserveConfig, + ) -> Result { let env_owner = self.owner.insecure_clone(); let mint = create_token_mint(&mut self.svm, &env_owner, decimals, None).unwrap(); self.set_price_for( @@ -233,17 +247,16 @@ impl Env { vec![instruction], &[market_owner], &market_owner.pubkey(), - ) - .unwrap(); + )?; - ReserveHandle { + Ok(ReserveHandle { mint, decimals, reserve, share_mint, liquidity_vault, price_feed, - } + }) } pub fn current_timestamp(&self) -> i64 { @@ -825,33 +838,6 @@ impl Env { repay: &ReserveHandle, collateral: &ReserveHandle, amount: u64, - ) -> Result<(), String> { - self.try_liquidate_with_rent_to( - liquidator, - obligation, - deposit_reserves, - borrow_reserves, - repay, - collateral, - amount, - None, - ) - } - - /// Liquidate, naming `obligation_owner` as the account the collateral - /// vault's rent returns to if the seizure empties it (`None` passes the - /// obligation's real owner, the only account the program accepts). - #[allow(clippy::too_many_arguments)] - pub fn try_liquidate_with_rent_to( - &mut self, - liquidator: &Keypair, - obligation: Address, - deposit_reserves: &[&ReserveHandle], - borrow_reserves: &[&ReserveHandle], - repay: &ReserveHandle, - collateral: &ReserveHandle, - amount: u64, - obligation_owner: Option
, ) -> Result<(), String> { let repay_source = ata(&liquidator.pubkey(), &repay.mint); // Create the destination ATA only on the first call, so a test can @@ -867,8 +853,6 @@ impl Env { .unwrap(); } let vault = self.obligation_share_vault(collateral, obligation); - let obligation_owner = - obligation_owner.unwrap_or_else(|| self.obligation(obligation).owner); let mut all: Vec<&ReserveHandle> = deposit_reserves.to_vec(); all.extend_from_slice(borrow_reserves); @@ -883,7 +867,6 @@ impl Env { accounts: lending::accounts::LiquidateObligation { obligation, liquidator: liquidator.pubkey(), - obligation_owner, repay_reserve: repay.reserve, collateral_reserve: collateral.reserve, repay_price_feed: repay.price_feed, diff --git a/finance/lending/anchor/programs/lending/tests/test_liquidation.rs b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs index dbbb7d7a..4420d827 100644 --- a/finance/lending/anchor/programs/lending/tests/test_liquidation.rs +++ b/finance/lending/anchor/programs/lending/tests/test_liquidation.rs @@ -151,19 +151,23 @@ fn over_seizing_liquidation_rejected_smaller_succeeds() { ); } +/// Price at which the capped repayment seizes the whole deposit: at $0.3675 +/// the 1,000 collateral units are worth $367.50, and the close factor caps the +/// repayment at half the $700 debt, $350, whose value plus the 5% bonus is +/// exactly $367.50. +const PRICE_TO_SEIZE_EVERYTHING: i128 = 367_500_000_000_000_000; + /// A seizure that takes every collateral share removes the deposit entry and -/// closes the collateral vault, returning its rent to the obligation's owner, -/// who paid it, not to the liquidator who sent the transaction. -/// -/// At $0.3675 the 1,000 collateral units are worth $367.50, and the close -/// factor caps the repayment at half the $700 debt, $350, whose value plus -/// the 5% bonus is exactly $367.50: the whole deposit. +/// closes the collateral vault. Its rent goes into the obligation account, not +/// to any wallet, so liquidation takes no account the borrower controls; the +/// owner's wallet is untouched until `close_obligation` hands it back. #[test] -fn seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner() { +fn seizing_all_collateral_closes_the_vault_into_the_obligation() { let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); - env.set_price(collateral.mint, 367_500_000_000_000_000); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); let vault = env.obligation_share_vault(&collateral, obligation); let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); let owner_before = env.sol_balance(borrower.pubkey()); env.try_liquidate( @@ -184,25 +188,30 @@ fn seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner() { assert!(env.obligation(obligation).deposits.is_empty()); assert!(!env.account_is_open(vault)); assert_eq!( - env.sol_balance(borrower.pubkey()), - owner_before + vault_rent, - "the vault's rent must return to the owner who paid it" + env.sol_balance(obligation), + obligation_before + vault_rent, + "the vault's rent must land in the obligation" ); + assert_eq!(env.sol_balance(borrower.pubkey()), owner_before); } -/// The vault's rent belongs to the owner who paid it, so a liquidator cannot -/// name another account as `obligation_owner` to send it elsewhere; `address -/// = obligation.owner` refuses before anything moves. The account named is a -/// third party's wallet: v2 refuses the liquidator's own key passed twice as -/// a duplicate mutable account before the address constraint runs. +/// Share tokens sent straight to the vault are not recorded in the +/// obligation. A seizure that empties the vault sweeps them to the liquidator +/// with the seized shares, so the vault can still close. #[test] -fn liquidator_cannot_redirect_the_vault_rent() { +fn seizing_all_collateral_sweeps_donated_shares_to_the_liquidator() { let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); - env.set_price(collateral.mint, 367_500_000_000_000_000); let vault = env.obligation_share_vault(&collateral, obligation); - let stranger = env.create_user(); + let donor = env.create_user(); + env.fund(&donor, collateral.mint, 5_000_000); + env.supply(&donor, &collateral, 5_000_000); + env.send_shares(&donor, &collateral, vault, 5_000_000); + assert_eq!(env.token_balance(vault), 1_005_000_000); - let result = env.try_liquidate_with_rent_to( + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); + env.try_liquidate( &liquidator, obligation, &[&collateral], @@ -210,18 +219,207 @@ fn liquidator_cannot_redirect_the_vault_rent() { &borrow, &collateral, 350_000_000, - Some(stranger.pubkey()), + ) + .unwrap(); + + assert_eq!( + env.token_balance(ata(&liquidator.pubkey(), &collateral.share_mint)), + 1_005_000_000 ); - let anchor_lang::Error::Custom(code) = - anchor_lang::Error::from(anchor_lang::ErrorCode::ConstraintAddress) - else { - panic!("a constraint error converts to a custom code"); - }; - let message = result.expect_err("the vault's rent may only go to the obligation's owner"); - assert!( - message.contains(&format!("Custom({code})")), - "expected ConstraintAddress (Custom({code})), got: {message}" + assert!(!env.account_is_open(vault)); + assert_eq!(env.sol_balance(obligation), obligation_before + vault_rent); +} + +/// A seizure that leaves shares behind leaves the vault open, holding exactly +/// the shares the obligation still records. +#[test] +fn partial_liquidation_keeps_the_vault_open() { + let (mut env, collateral, borrow, _borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, cents(80)); + let vault = env.obligation_share_vault(&collateral, obligation); + + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ) + .unwrap(); + + let seized = env.token_balance(ata(&liquidator.pubkey(), &collateral.share_mint)); + assert!(seized > 0); + assert!(env.account_is_open(vault)); + let remaining = env.obligation(obligation).deposits[0].deposited_shares; + assert_eq!(remaining, 1_000_000_000 - seized); + assert_eq!(env.token_balance(vault), remaining); +} + +/// The vault rent a liquidation left in the obligation returns to the owner +/// when they close it: once the remaining debt is repaid, `close_obligation` +/// pays out the obligation's own rent and the vault's together. +#[test] +fn close_obligation_after_full_liquidation_returns_both_rents_to_the_owner() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_rent = env.sol_balance(obligation); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + + // The seizure repaid $350 of the $700; the borrower repays the rest. + env.repay(&borrower, obligation, &borrow, 1_000_000_000); + assert!(env.obligation(obligation).borrows.is_empty()); + assert_eq!(env.sol_balance(obligation), obligation_rent + vault_rent); + + let fee = env.transaction_fee(&borrower); + let owner_before = env.sol_balance(borrower.pubkey()); + env.try_close_obligation(&borrower, obligation).unwrap(); + assert!(!env.account_is_open(obligation)); + assert_eq!( + env.sol_balance(borrower.pubkey()), + owner_before + obligation_rent + vault_rent - fee, + "both rents must return to the owner" ); +} + +/// An owner may liquidate their own unhealthy position: they repay their own +/// debt and take their own collateral at the bonus. Pointless economically, +/// but not refused. +#[test] +fn owner_can_liquidate_their_own_obligation() { + let (mut env, collateral, borrow, borrower, obligation, _liquidator) = setup(); + env.set_price(collateral.mint, cents(80)); + let owner_borrow_tokens = ata(&borrower.pubkey(), &borrow.mint); + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + assert_eq!(env.token_balance(owner_borrow_tokens), 700_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + + env.try_liquidate( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ) + .unwrap(); + + assert_eq!(env.token_balance(owner_borrow_tokens), 600_000_000); + let seized = env.token_balance(owner_shares); + assert!(seized > 0); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 1_000_000_000 - seized + ); +} + +/// An owner may also liquidate their own position down to nothing: the +/// seizure empties the vault, which closes into the obligation account, while +/// the owner signs as the liquidator. The owner gets every share back and the +/// vault's rent waits in the obligation. +#[test] +fn owner_can_liquidate_their_own_obligation_to_empty() { + let (mut env, collateral, borrow, borrower, obligation, _liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_before = env.sol_balance(obligation); + let owner_borrow_tokens = ata(&borrower.pubkey(), &borrow.mint); + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + assert_eq!(env.token_balance(owner_borrow_tokens), 700_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + + env.try_liquidate( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + + assert_eq!(env.token_balance(owner_borrow_tokens), 350_000_000); + assert_eq!(env.token_balance(owner_shares), 1_000_000_000); + assert!(env.obligation(obligation).deposits.is_empty()); + assert!(!env.account_is_open(vault)); + assert_eq!(env.sol_balance(obligation), obligation_before + vault_rent); +} + +/// A liquidation that takes all the collateral and half the debt leaves the +/// obligation holding debt and nothing else, and `close_obligation` refuses it +/// until that debt is repaid. +#[test] +fn close_obligation_refused_while_debt_remains_after_full_liquidation() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + let state = env.obligation(obligation); + assert!(state.deposits.is_empty()); + assert_eq!(state.borrows.len(), 1); + + let result = env.try_close_obligation(&borrower, obligation); + common::assert_program_error!(result, LendingError::ObligationNotEmpty); + assert!(env.account_is_open(obligation)); +} + +/// After a liquidation closes the vault, the owner can post collateral again: +/// the deposit recreates the vault, paid for by the owner, and the old vault's +/// rent stays in the obligation. +#[test] +fn redeposit_after_full_liquidation_recreates_the_vault() { + let (mut env, collateral, borrow, borrower, obligation, liquidator) = setup(); + env.set_price(collateral.mint, PRICE_TO_SEIZE_EVERYTHING); + let vault = env.obligation_share_vault(&collateral, obligation); + let vault_rent = env.sol_balance(vault); + let obligation_rent = env.sol_balance(obligation); + env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 350_000_000, + ) + .unwrap(); + assert!(!env.account_is_open(vault)); + + // The owner has no shares left, so the liquidator hands 600 back. + let owner_shares = ata(&borrower.pubkey(), &collateral.share_mint); + env.send_shares(&liquidator, &collateral, owner_shares, 600_000_000); + env.post_collateral(&borrower, obligation, &collateral, 600_000_000); + assert!(env.account_is_open(vault)); - assert_eq!(env.token_balance(vault), 1_000_000_000); + assert_eq!(env.sol_balance(vault), vault_rent); + assert_eq!(env.token_balance(vault), 600_000_000); + assert_eq!(env.token_balance(owner_shares), 0); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 600_000_000 + ); + assert_eq!(env.sol_balance(obligation), obligation_rent + vault_rent); } diff --git a/finance/lending/anchor/programs/lending/tests/test_reserve.rs b/finance/lending/anchor/programs/lending/tests/test_reserve.rs index 0e38bdfe..8752f83a 100644 --- a/finance/lending/anchor/programs/lending/tests/test_reserve.rs +++ b/finance/lending/anchor/programs/lending/tests/test_reserve.rs @@ -3,7 +3,7 @@ mod common; use lending::errors::LendingError; use common::{default_config, Env}; -use lending::constants::FIXED_POINT_SCALE; +use lending::constants::{BORROW_RATE_CEILING_BPS, FIXED_POINT_SCALE}; use lending::state::Reserve; #[test] @@ -91,9 +91,9 @@ fn accepts_valid_config_update() { let usdc = env.add_reserve(6, common::dollars(1), default_config()); let mut updated = default_config(); - updated.loan_to_value_bps = 6_000; + updated.loan_to_value_bps = 7_800; env.try_update_config(&usdc, updated).unwrap(); - assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 6_000); + assert_eq!(env.reserve(&usdc).config.loan_to_value_bps, 7_800); } /// A reserve at 50% utilization with a borrower drawing half the pool, so the @@ -217,3 +217,243 @@ fn a_config_update_accrues_at_the_old_rates_first() { factor_after(&before, seconds as u128) ); } + +/// A config whose rate curve is `min`/`optimal`/`max`, every other field the +/// default. +fn config_with_curve(min: u16, optimal: u16, max: u16) -> lending::state::ReserveConfig { + let mut config = default_config(); + config.min_borrow_rate_bps = min; + config.optimal_borrow_rate_bps = optimal; + config.max_borrow_rate_bps = max; + config +} + +/// A reserve cannot be created with a rate above the 300% a year ceiling. +#[test] +fn rejects_borrow_rate_above_ceiling_at_initialize() { + let mut env = Env::new(); + let owner = env.owner.insecure_clone(); + let market = env.market; + let above = config_with_curve(200, 2_000, BORROW_RATE_CEILING_BPS + 1); + let result = env.try_add_reserve_to(&owner, market, 6, common::dollars(1), above); + common::assert_program_error!(result.map(|_| ()), LendingError::BorrowRateAboveCeiling); +} + +/// No rate field may be raised above the ceiling on a live reserve. Each of +/// `min`, `optimal` and `max` is tried one past it on its own, with the other +/// two inside the ceiling. For `min` and `optimal` the curve is then misordered +/// as well, and the ceiling check runs first, so each case returns +/// `BorrowRateAboveCeiling` only through its own field's clause. +#[test] +fn rejects_borrow_rate_above_ceiling_on_update() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let above = BORROW_RATE_CEILING_BPS + 1; + for curve in [ + (above, 2_000, 15_000), + (200, above, 15_000), + (200, 2_000, above), + ] { + let result = env.try_update_config(&borrow, config_with_curve(curve.0, curve.1, curve.2)); + common::assert_program_error!(result, LendingError::BorrowRateAboveCeiling); + } + let config = env.reserve(&borrow).config; + assert_eq!(config.min_borrow_rate_bps, 200); + assert_eq!(config.optimal_borrow_rate_bps, 2_000); + assert_eq!(config.max_borrow_rate_bps, 15_000); +} + +/// A rate exactly at the ceiling is accepted, at creation and on update. +#[test] +fn accepts_borrow_rate_at_ceiling() { + let mut env = Env::new(); + let at_ceiling = config_with_curve(200, 2_000, BORROW_RATE_CEILING_BPS); + let usdc = env.add_reserve(6, common::dollars(1), at_ceiling); + assert_eq!( + env.reserve(&usdc).config.max_borrow_rate_bps, + BORROW_RATE_CEILING_BPS + ); + + let flat_at_ceiling = config_with_curve( + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + ); + env.try_update_config(&usdc, flat_at_ceiling).unwrap(); + let config = env.reserve(&usdc).config; + assert_eq!(config.min_borrow_rate_bps, BORROW_RATE_CEILING_BPS); + assert_eq!(config.optimal_borrow_rate_bps, BORROW_RATE_CEILING_BPS); + assert_eq!(config.max_borrow_rate_bps, BORROW_RATE_CEILING_BPS); +} + +/// Lowering the liquidation threshold of a reserve backing an open borrow is +/// refused: it would move the line the borrower is measured against. +#[test] +fn rejects_lowering_liquidation_threshold() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + let mut lower = default_config(); + lower.liquidation_threshold_bps = 7_900; + let result = env.try_update_config(&collateral, lower); + common::assert_program_error!(result, LendingError::RiskLimitLowered); + assert_eq!( + env.reserve(&collateral).config.liquidation_threshold_bps, + 8_000 + ); +} + +/// Lowering the loan-to-value is accepted, down to 0: it limits only new +/// borrows, so the open borrow stays healthy and cannot be liquidated, while a +/// new borrow past the lower limit is refused. +#[test] +fn accepts_lowering_loan_to_value() { + let mut env = Env::new(); + let collateral = env.add_reserve(6, common::dollars(1), default_config()); + let borrow = env.add_reserve(6, common::dollars(1), default_config()); + let supplier = env.create_user(); + env.fund(&supplier, borrow.mint, 1_000_000_000); + env.supply(&supplier, &borrow, 1_000_000_000); + let borrower = env.create_user(); + env.fund(&borrower, collateral.mint, 1_000_000_000); + env.fund(&borrower, borrow.mint, 0); + env.supply(&borrower, &collateral, 1_000_000_000); + let obligation = env.initialize_obligation(&borrower); + env.post_collateral(&borrower, obligation, &collateral, 1_000_000_000); + // $700 against $1,000 of collateral: inside the 75% loan-to-value. + env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[], + &borrow, + 700_000_000, + ) + .unwrap(); + + let mut frozen = default_config(); + frozen.loan_to_value_bps = 0; + env.try_update_config(&collateral, frozen).unwrap(); + assert_eq!(env.reserve(&collateral).config.loan_to_value_bps, 0); + + let result = env.try_borrow( + &borrower, + obligation, + &[&collateral], + &[&borrow], + &borrow, + 1, + ); + common::assert_program_error!(result, LendingError::BorrowTooLarge); + + let liquidator = env.create_user(); + env.fund(&liquidator, borrow.mint, 1_000_000_000); + let result = env.try_liquidate( + &liquidator, + obligation, + &[&collateral], + &[&borrow], + &borrow, + &collateral, + 100_000_000, + ); + common::assert_program_error!(result, LendingError::ObligationHealthy); + assert_eq!( + env.obligation(obligation).deposits[0].deposited_shares, + 1_000_000_000 + ); +} + +/// Raising both limits loosens the reserve for every borrower and is accepted. +#[test] +fn accepts_raising_loan_to_value_and_liquidation_threshold() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + let mut higher = default_config(); + higher.loan_to_value_bps = 7_600; + higher.liquidation_threshold_bps = 8_100; + env.try_update_config(&collateral, higher).unwrap(); + let config = env.reserve(&collateral).config; + assert_eq!(config.loan_to_value_bps, 7_600); + assert_eq!(config.liquidation_threshold_bps, 8_100); +} + +/// An update that leaves both limits where they are and moves the rate curve +/// within the ceiling is accepted. +#[test] +fn accepts_curve_change_with_risk_limits_unchanged() { + let mut env = Env::new(); + let (_collateral, borrow) = half_borrowed_reserve(&mut env); + let steeper = config_with_curve(500, 5_000, BORROW_RATE_CEILING_BPS); + env.try_update_config(&borrow, steeper).unwrap(); + let config = env.reserve(&borrow).config; + assert_eq!(config.loan_to_value_bps, 7_500); + assert_eq!(config.liquidation_threshold_bps, 8_000); + assert_eq!(config.min_borrow_rate_bps, 500); + assert_eq!(config.optimal_borrow_rate_bps, 5_000); + assert_eq!(config.max_borrow_rate_bps, BORROW_RATE_CEILING_BPS); +} + +/// A config whose liquidation threshold and bonus are `threshold` and +/// `bonus`, every other field the default. +fn config_with_threshold_and_bonus(threshold: u16, bonus: u16) -> lending::state::ReserveConfig { + let mut config = default_config(); + config.liquidation_threshold_bps = threshold; + config.liquidation_bonus_bps = bonus; + config +} + +/// A reserve cannot be created with a threshold so high that a liquidation at +/// it could not pay the bonus from the collateral: 8,000 x 12,501 is past +/// 10,000 x 10,000. +#[test] +fn rejects_unpayable_liquidation_bonus_at_initialize() { + let mut env = Env::new(); + let owner = env.owner.insecure_clone(); + let market = env.market; + let unpayable = config_with_threshold_and_bonus(8_000, 2_501); + let result = env.try_add_reserve_to(&owner, market, 6, common::dollars(1), unpayable); + common::assert_program_error!(result.map(|_| ()), LendingError::LiquidationBonusUnpayable); +} + +/// An update may not cross the bound either, by raising the bonus or by +/// raising the threshold, and the config is left as it was. +#[test] +fn rejects_unpayable_liquidation_bonus_on_update() { + let mut env = Env::new(); + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + // 8,000 x 12,501 and 9,524 x 10,500 are each past 100,000,000. + for (threshold, bonus) in [(8_000, 2_501), (9_524, 500)] { + let result = env.try_update_config( + &collateral, + config_with_threshold_and_bonus(threshold, bonus), + ); + common::assert_program_error!(result, LendingError::LiquidationBonusUnpayable); + } + let config = env.reserve(&collateral).config; + assert_eq!(config.liquidation_threshold_bps, 8_000); + assert_eq!(config.liquidation_bonus_bps, 500); +} + +/// A threshold and bonus exactly at the bound are accepted, at creation and on +/// update: 8,000 x 12,500 is exactly 100,000,000, and 9,523 is the highest +/// threshold a 5% bonus allows (9,523 x 10,500 is 99,991,500). +#[test] +fn accepts_liquidation_bonus_at_the_bound() { + let mut env = Env::new(); + let usdc = env.add_reserve( + 6, + common::dollars(1), + config_with_threshold_and_bonus(8_000, 2_500), + ); + assert_eq!(env.reserve(&usdc).config.liquidation_bonus_bps, 2_500); + + let (collateral, _borrow) = half_borrowed_reserve(&mut env); + env.try_update_config(&collateral, config_with_threshold_and_bonus(8_000, 2_500)) + .unwrap(); + assert_eq!(env.reserve(&collateral).config.liquidation_bonus_bps, 2_500); + env.try_update_config(&collateral, config_with_threshold_and_bonus(9_523, 500)) + .unwrap(); + let config = env.reserve(&collateral).config; + assert_eq!(config.liquidation_threshold_bps, 9_523); + assert_eq!(config.liquidation_bonus_bps, 500); +} diff --git a/finance/lending/quasar/CHANGELOG.md b/finance/lending/quasar/CHANGELOG.md index ee0ef87e..a210b960 100644 --- a/finance/lending/quasar/CHANGELOG.md +++ b/finance/lending/quasar/CHANGELOG.md @@ -4,31 +4,57 @@ ### Changed +- Cap the borrow rate and keep the liquidation bonus payable. + `validate_config` now refuses any of `min_borrow_rate_bps`, + `optimal_borrow_rate_bps` or `max_borrow_rate_bps` above the new + `BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year) with the new + `BorrowRateAboveCeiling` error, so `initialize_reserve` can no longer set a + curve up to the 655% a year a bare u16 allows. It also refuses a config + where `liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds + `10_000 * 10_000` with the new `LiquidationBonusUnpayable` error, so a + liquidation at the threshold can always pay its bonus out of the + collateral. This port has no `update_reserve_config`, so the Anchor + versions' ratchet on `liquidation_threshold_bps` has nothing to guard here. + Tested by `rejects_borrow_rate_above_ceiling_at_initialize` (each rate field + alone above the ceiling), `accepts_borrow_rate_at_ceiling`, + `rejects_unpayable_liquidation_bonus_at_initialize` and + `accepts_liquidation_bonus_at_the_bound`. - Close the collateral vault when its last share leaves. A withdrawal or a liquidation that takes the last deposited share now also closes the - obligation's share vault, rent to the obligation's owner, who paid it in + obligation's share vault, whose rent the obligation's owner paid in `deposit_obligation_collateral` (`init(idempotent)` recreates it on a later deposit). The vault's whole balance moves out first, to the owner on a withdrawal and to the liquidator on a liquidation, so share tokens donated - straight to the vault cannot keep it open or make the withdrawal fail. - `liquidate_obligation` takes a new `obligation_owner` account - (`address = obligation.owner`) to receive the rent. Tested by - `full_withdraw_closes_the_vault_and_returns_its_rent`, + straight to the vault cannot keep it open or make the withdrawal fail. A + withdrawal closes the vault to the owner. A liquidation closes it into the + obligation account, and `close_obligation` returns that rent to the owner + with the obligation's own; `liquidate_obligation`'s accounts are + unchanged. Tested by `full_withdraw_closes_the_vault_and_returns_its_rent`, `partial_withdraw_keeps_the_vault_open`, `redeposit_after_full_withdraw_recreates_the_vault`, `donated_shares_cannot_keep_the_vault_open`, - `seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner` - and `liquidator_cannot_redirect_the_vault_rent` (`AddressMismatch`); + `seizing_all_collateral_closes_the_vault_into_the_obligation`, + `seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`, + `partial_liquidation_keeps_the_vault_open`, + `close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`, + `close_obligation_refused_while_debt_remains_after_full_liquidation`, + `redeposit_after_full_liquidation_recreates_the_vault`, + `owner_can_liquidate_their_own_obligation` and + `owner_can_liquidate_their_own_obligation_to_empty`; `debt_free_withdraw_needs_no_price` now asserts the vault is gone. - A debt-free borrower can always withdraw. `withdraw_obligation_collateral` valued the remaining collateral at the feed's price on every call, so a borrower with no debt could not take their collateral out while the feed was stale or silent. Now the handler reads no price and runs no health check when `borrowed_principal` is zero (repaying the last unit zeroes it); - the price accounts are still passed and checked to be the reserves' own, - but their values are not read. Every check stays for an obligation with - debt. Tested by `debt_free_withdraw_needs_no_price`, which lets the price - go stale and withdraws the whole deposit, + the collateral price account is still checked to be the collateral + reserve's own, but its value is not read, and the borrow reserve and borrow + price accounts are ignored. Every check stays for an obligation with debt. + Tested by `debt_free_withdraw_needs_no_price`, which lets the price go + stale and withdraws the whole deposit, + `debt_free_withdraw_ignores_the_borrow_accounts`, which passes an unrelated + borrow reserve and feed and has the same accounts refused + (`WrongReserve`) once there is debt, `withdraw_after_full_repay_needs_no_price`, and `withdraw_with_debt_is_refused_while_the_price_is_stale`, which asserts the existing `StalePrice` refusal. diff --git a/finance/lending/quasar/README.md b/finance/lending/quasar/README.md index f52bc4eb..444b06b2 100644 --- a/finance/lending/quasar/README.md +++ b/finance/lending/quasar/README.md @@ -28,7 +28,22 @@ fixed-size accounts, so this port follows that idiom: `update_reserve_config`: a reserve's loan-to-value, liquidation threshold and bonus, close factor, reserve factor, interest-rate curve and oracle confidence limit are set by `initialize_reserve` and never change, so the - bounds the Anchor version checks on update are checked there. + bounds the Anchor version checks on update are checked there. Those include + the borrow rate ceiling: no rate on the curve may exceed + `BORROW_RATE_CEILING_BPS` (30,000 bps, 300% a year), or the reserve is + refused with `BorrowRateAboveCeiling`. They also include the bound that + keeps the liquidation bonus payable: a reserve whose + `liquidation_threshold_bps * (10_000 + liquidation_bonus_bps)` exceeds + `10_000 * 10_000` is refused with `LiquidationBonusUnpayable` + (`rejects_unpayable_liquidation_bonus_at_initialize`, + `accepts_liquidation_bonus_at_the_bound`). A position becomes liquidatable + once its debt passes the threshold share of its collateral, and the + liquidator takes that debt plus the bonus in collateral, so the bound keeps + a liquidation at the threshold payable from the collateral rather than + leaving the suppliers bad debt. The default 80% threshold with a 5% bonus + gives 8,000 × 10,500 = 84,000,000, inside the bound. With no update handler, + the Anchor version's rule that an update may only raise the liquidation + threshold holds here trivially. - **A hand-declared `LastRestartSlot` sysvar.** quasar-lang ships only the Clock and Rent sysvars, so `src/last_restart.rs` declares the 8-byte layout @@ -144,16 +159,30 @@ collateral is out, `close_obligation` returns the account's rent to the owner; it refuses with `ObligationNotEmpty` while any shares or principal remain, and only the owner may close it. -The collateral vault closes when its last share leaves, whether a withdrawal -or a liquidation takes it, and its rent goes back to the obligation's owner, -who paid it when `deposit_obligation_collateral` created the vault -(`init(idempotent)` creates it again on a later deposit). The handler moves -the vault's whole balance out before closing it, so share tokens someone sent -straight to the vault cannot keep it open or block the withdrawal -(`donated_shares_cannot_keep_the_vault_open`). `liquidate_obligation` takes the -owner as `obligation_owner` for the rent and refuses any other account -(`liquidator_cannot_redirect_the_vault_rent`). Every account the program -creates for a borrower therefore closes, with its rent returned. +The collateral vault closes when its last share leaves. The rent was paid by +the obligation's owner when `deposit_obligation_collateral` created the vault +(`init(idempotent)` creates it again on a later deposit). A withdrawal that +empties the vault returns that rent to the owner straight away. A liquidation +that empties it closes it into the obligation account instead, so +liquidation takes no account the borrower controls +(`seizing_all_collateral_closes_the_vault_into_the_obligation`), and the owner +can still liquidate their own position, partly +(`owner_can_liquidate_their_own_obligation`) or down to an empty vault +(`owner_can_liquidate_their_own_obligation_to_empty`). `close_obligation` later +hands the owner the obligation's own rent and the vault's together +(`close_obligation_after_full_liquidation_returns_both_rents_to_the_owner`), +and a later deposit recreates the vault +(`redeposit_after_full_liquidation_recreates_the_vault`). +Either way the whole vault balance moves out before the vault closes, so +share tokens someone sent straight to the vault cannot keep it open or block +the withdrawal (`donated_shares_cannot_keep_the_vault_open`, +`seizing_all_collateral_sweeps_donated_shares_to_the_liquidator`). + +Every account the program creates for a borrower closes, with its rent +returned, once the position is fully unwound. An obligation that a +liquidation leaves holding debt and no collateral is not unwound: +`close_obligation` refuses it until that debt is repaid +(`close_obligation_refused_while_debt_remains_after_full_liquidation`). ## Setup diff --git a/finance/lending/quasar/src/constants.rs b/finance/lending/quasar/src/constants.rs index f6d69176..731d2215 100644 --- a/finance/lending/quasar/src/constants.rs +++ b/finance/lending/quasar/src/constants.rs @@ -14,6 +14,13 @@ pub const FIXED_POINT_SCALE_DECIMALS: i32 = 18; /// 100% expressed in basis points. pub const BPS_DENOMINATOR: u128 = 10_000; +/// Highest annual borrow rate, in basis points, any point on a reserve's rate +/// curve may be set to: 30,000 bps, or 300% a year. `math::validate_config` +/// refuses a `min_borrow_rate_bps`, `optimal_borrow_rate_bps` or +/// `max_borrow_rate_bps` above it (`BorrowRateAboveCeiling`), so a reserve can +/// never charge the 655% a year a bare u16 would allow. +pub const BORROW_RATE_CEILING_BPS: u16 = 30_000; + /// Seconds in a 365-day year: the divisor that turns an annual rate into the /// per-second rate interest accrues at. Interest runs on the wall clock, not /// the slot count, because a rate quoted per year is a promise about wall-clock diff --git a/finance/lending/quasar/src/error.rs b/finance/lending/quasar/src/error.rs index 2b1c822f..d8d9f954 100644 --- a/finance/lending/quasar/src/error.rs +++ b/finance/lending/quasar/src/error.rs @@ -23,4 +23,11 @@ pub enum LendingError { OracleConfidenceTooWide, /// The obligation still holds collateral or debt and cannot be closed. ObligationNotEmpty, + /// A rate on the reserve's borrow curve is above the program's ceiling of + /// 30,000 bps (300% a year). + BorrowRateAboveCeiling, + /// The liquidation threshold is too high for the collateral to pay the + /// liquidation bonus: `liquidation_threshold_bps * (10_000 + + /// liquidation_bonus_bps)` exceeds `10_000 * 10_000`. + LiquidationBonusUnpayable, } diff --git a/finance/lending/quasar/src/instructions/position.rs b/finance/lending/quasar/src/instructions/position.rs index 48ec4998..7971d0b6 100644 --- a/finance/lending/quasar/src/instructions/position.rs +++ b/finance/lending/quasar/src/instructions/position.rs @@ -364,9 +364,9 @@ impl RepayObligationLiquidity { /// collateral that remains. With no debt the collateral backs nothing, so no /// price is read and no health check runs: the whole deposit can come out /// whatever the feeds are doing, since a borrower who owes nothing must never -/// be locked in by a stale or silent oracle. The price accounts are still -/// passed, and checked to be the reserves' own, but their values are not -/// read. +/// be locked in by a stale or silent oracle. The collateral price account is +/// still checked to be the collateral reserve's own, but its value is not +/// read; the borrow reserve and borrow price accounts are ignored. /// /// A withdrawal that takes the last share closes the collateral vault and /// returns its rent to the owner, who paid it when @@ -542,7 +542,9 @@ impl WithdrawObligationCollateral { /// `ObligationNotEmpty`. Only the owner may close it (`has_one(owner)`), since /// the rent is theirs and a stranger could otherwise close a position its /// owner means to use again. The account itself closes through the -/// `close(dest = owner)` constraint once the handler returns. +/// `close(dest = owner)` constraint once the handler returns, which hands the +/// owner every lamport it holds: its own rent, plus the rent of a collateral +/// vault a liquidation emptied and closed into it. #[derive(Accounts)] pub struct CloseObligation { #[account(mut)] @@ -574,20 +576,20 @@ impl CloseObligation { // liquidate_obligation // --------------------------------------------------------------------------- -/// A seizure that takes the last share closes the collateral vault, rent to -/// the obligation's owner (`obligation_owner`), who paid it. The whole vault -/// balance goes to the liquidator first, so share tokens someone sent straight -/// to the vault cannot keep it open. +/// A seizure that takes the last share closes the collateral vault. The whole +/// vault balance goes to the liquidator first, so share tokens someone sent +/// straight to the vault cannot keep it open. The vault's rent goes into the +/// obligation account itself, not to the owner's wallet: liquidation then +/// takes no account the borrower controls, so nothing the borrower does to +/// their wallet can make it fail, and the owner can still liquidate their own +/// position. The rent returns to the owner, who paid it, when +/// `close_obligation` closes the obligation with every lamport it holds. #[derive(Accounts)] pub struct LiquidateObligation { #[account(mut)] pub liquidator: Signer, #[account(mut, has_one(lending_market))] pub obligation: Account, - /// The obligation's owner, who paid the collateral vault's rent; receives - /// it back if this seizure empties the vault. - #[account(mut, address = obligation.owner)] - pub obligation_owner: UncheckedAccount, pub lending_market: Account, #[account(mut, has_one(lending_market), has_one(share_mint))] pub collateral_reserve: Account, @@ -792,11 +794,7 @@ impl LiquidateObligation { .invoke_signed(&seeds)?; if empties_vault { self.token_program - .close_account( - &self.obligation_vault, - &self.obligation_owner, - &self.obligation, - ) + .close_account(&self.obligation_vault, &self.obligation, &self.obligation) .invoke_signed(&seeds)?; } Ok(()) diff --git a/finance/lending/quasar/src/math.rs b/finance/lending/quasar/src/math.rs index 83ea7470..2f05a240 100644 --- a/finance/lending/quasar/src/math.rs +++ b/finance/lending/quasar/src/math.rs @@ -6,8 +6,8 @@ use quasar_lang::prelude::*; use crate::{ constants::{ - BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, MINIMUM_SHARES, - SECONDS_PER_YEAR, + BORROW_RATE_CEILING_BPS, BPS_DENOMINATOR, FIXED_POINT_SCALE, FIXED_POINT_SCALE_DECIMALS, + MINIMUM_SHARES, SECONDS_PER_YEAR, }, error::LendingError, }; @@ -274,6 +274,24 @@ pub fn validate_config( loan_to_value_bps <= liquidation_threshold_bps, LendingError::InvalidConfig ); + // A liquidation at the threshold must be able to pay its bonus out of the + // collateral: the debt is at most `threshold` of the collateral's value, + // and the liquidator takes that debt plus the bonus, so + // `threshold * (1 + bonus)` may not exceed 100%. Both fields are at most + // 10,000 here, so the product fits a u128 with room to spare. + require!( + (liquidation_threshold_bps as u128) * (BPS_DENOMINATOR + liquidation_bonus_bps as u128) + <= BPS_DENOMINATOR * BPS_DENOMINATOR, + LendingError::LiquidationBonusUnpayable + ); + // No point on the rate curve may exceed the ceiling, so no reserve can be + // created charging an arbitrary rate. + require!( + min_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && optimal_borrow_rate_bps <= BORROW_RATE_CEILING_BPS + && max_borrow_rate_bps <= BORROW_RATE_CEILING_BPS, + LendingError::BorrowRateAboveCeiling + ); require!( min_borrow_rate_bps <= optimal_borrow_rate_bps && optimal_borrow_rate_bps <= max_borrow_rate_bps, diff --git a/finance/lending/quasar/src/tests.rs b/finance/lending/quasar/src/tests.rs index ff382a75..d3007d54 100644 --- a/finance/lending/quasar/src/tests.rs +++ b/finance/lending/quasar/src/tests.rs @@ -6,7 +6,7 @@ use { crate::{ - constants::{BPS_DENOMINATOR, FIXED_POINT_SCALE}, + constants::{BORROW_RATE_CEILING_BPS, BPS_DENOMINATOR, FIXED_POINT_SCALE}, cpi::{ BorrowObligationLiquidityInstruction, CloseObligationInstruction, DepositObligationCollateralInstruction, DepositReserveLiquidityInstruction, @@ -20,7 +20,6 @@ use { LendingMarket, LiquidityVaultPda, Obligation, ObligationVaultPda, Reserve, ShareMintPda, }, }, - quasar_lang::error::QuasarError, quasar_test::prelude::*, }; @@ -255,6 +254,59 @@ fn initialize_reserve_with_confidence_limit( }) } +/// Initialize a reserve with the default risk limits and the rate curve +/// `min`/`optimal`/`max`. +fn initialize_reserve_with_curve( + test: &mut Test, + w: &Pdas, + the_mint: Pubkey, + min_borrow_rate_bps: u16, + optimal_borrow_rate_bps: u16, + max_borrow_rate_bps: u16, +) -> Outcome { + test.send(InitializeReserveInstruction { + owner: OWNER, + lending_market: w.market, + liquidity_mint: the_mint, + loan_to_value_bps: 7_500, + liquidation_threshold_bps: 8_000, + liquidation_bonus_bps: 500, + close_factor_bps: 5_000, + reserve_factor_bps: 1_000, + optimal_utilization_bps: 8_000, + min_borrow_rate_bps, + optimal_borrow_rate_bps, + max_borrow_rate_bps, + max_confidence_bps: DEFAULT_MAX_CONFIDENCE_BPS, + }) +} + +/// Initialize a reserve with the default config but the liquidation threshold +/// and bonus `liquidation_threshold_bps` and `liquidation_bonus_bps`. +fn initialize_reserve_with_threshold_and_bonus( + test: &mut Test, + w: &Pdas, + the_mint: Pubkey, + liquidation_threshold_bps: u16, + liquidation_bonus_bps: u16, +) -> Outcome { + test.send(InitializeReserveInstruction { + owner: OWNER, + lending_market: w.market, + liquidity_mint: the_mint, + loan_to_value_bps: 7_500, + liquidation_threshold_bps, + liquidation_bonus_bps, + close_factor_bps: 5_000, + reserve_factor_bps: 1_000, + optimal_utilization_bps: 8_000, + min_borrow_rate_bps: 200, + optimal_borrow_rate_bps: 2_000, + max_borrow_rate_bps: 15_000, + max_confidence_bps: DEFAULT_MAX_CONFIDENCE_BPS, + }) +} + /// Create the market and both reserves, then open each reserve with the /// owner's deposit so the withheld minimum is in place and later deposits mint /// shares one-for-one until interest accrues. @@ -431,32 +483,39 @@ fn close_obligation(test: &mut Test, w: &Pdas) -> Outcome { } fn liquidate(test: &mut Test, w: &Pdas, amount: u64) -> Outcome { - liquidate_with_rent_to(test, w, BORROWER, amount) + liquidate_as( + test, + w, + LIQUIDATOR, + LIQUIDATOR_BORROW, + LIQUIDATOR_COLLATERAL_SHARE, + amount, + ) } -/// Liquidate, naming `obligation_owner` as the account the collateral -/// vault's rent returns to if the seizure empties it. Only the obligation's -/// real owner is accepted. -fn liquidate_with_rent_to( +/// `liquidator` repays from `liquidator_liquidity` and receives the seized +/// shares in `liquidator_collateral`. +fn liquidate_as( test: &mut Test, w: &Pdas, - obligation_owner: Pubkey, + liquidator: Pubkey, + liquidator_liquidity: Pubkey, + liquidator_collateral: Pubkey, amount: u64, ) -> Outcome { test.send(LiquidateObligationInstruction { - liquidator: LIQUIDATOR, + liquidator, obligation: w.obligation, - obligation_owner, lending_market: w.market, collateral_reserve: w.collateral_reserve, collateral_price: w.collateral_price, share_mint: w.collateral_share_mint, - liquidator_collateral: LIQUIDATOR_COLLATERAL_SHARE, + liquidator_collateral, borrow_reserve: w.borrow_reserve, borrow_price: w.borrow_price, liquidity_mint: BORROW_MINT, liquidity_vault: w.borrow_vault, - liquidator_liquidity: LIQUIDATOR_BORROW, + liquidator_liquidity, amount, }) } @@ -648,6 +707,117 @@ fn rejects_zero_confidence_limit(test: &mut Test) { assert_eq!(u16::from(reserve.max_confidence_bps), 1); } +/// No rate on the curve may exceed the 300% a year ceiling. Each of `min`, +/// `optimal` and `max` is tried one past it on its own, with the other two +/// inside the ceiling, and each is refused at creation, the only place this +/// port sets a config. For `min` and `optimal` the curve is then misordered as +/// well, and the ceiling check runs first, so each case returns +/// `BorrowRateAboveCeiling` only through its own field's clause. +#[quasar_test] +fn rejects_borrow_rate_above_ceiling_at_initialize(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + + let above = BORROW_RATE_CEILING_BPS + 1; + for (min, optimal, max) in [ + (above, 2_000, 15_000), + (200, above, 15_000), + (200, 2_000, above), + ] { + initialize_reserve_with_curve(test, &w, COLLATERAL_MINT, min, optimal, max) + .fails_with(LendingError::BorrowRateAboveCeiling); + } +} + +/// A rate exactly at the ceiling is accepted. +#[quasar_test] +fn accepts_borrow_rate_at_ceiling(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + + initialize_reserve_with_curve( + test, + &w, + COLLATERAL_MINT, + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + BORROW_RATE_CEILING_BPS, + ) + .succeeds(); + let reserve = test.read::(w.collateral_reserve); + assert_eq!( + u16::from(reserve.min_borrow_rate_bps), + BORROW_RATE_CEILING_BPS + ); + assert_eq!( + u16::from(reserve.optimal_borrow_rate_bps), + BORROW_RATE_CEILING_BPS + ); + assert_eq!( + u16::from(reserve.max_borrow_rate_bps), + BORROW_RATE_CEILING_BPS + ); +} + +/// A reserve cannot be created with a threshold so high that a liquidation at +/// it could not pay the bonus from the collateral, whether the bonus or the +/// threshold crosses the bound: 8,000 x 12,501 and 9,524 x 10,500 are each +/// past 10,000 x 10,000. +#[quasar_test] +fn rejects_unpayable_liquidation_bonus_at_initialize(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + + for (threshold, bonus) in [(8_000, 2_501), (9_524, 500)] { + initialize_reserve_with_threshold_and_bonus(test, &w, COLLATERAL_MINT, threshold, bonus) + .fails_with(LendingError::LiquidationBonusUnpayable); + } +} + +/// A threshold and bonus exactly at the bound are accepted: 8,000 x 12,500 is +/// exactly 100,000,000, and 9,523 is the highest threshold a 5% bonus allows +/// (9,523 x 10,500 is 99,991,500). +#[quasar_test] +fn accepts_liquidation_bonus_at_the_bound(test: &mut Test) { + let w = base_world(test); + test.send(InitializeLendingMarketInstruction { + owner: OWNER, + quote_mint: QUOTE_MINT, + market_id: MARKET_ID, + }) + .succeeds(); + set_price(test, &w, COLLATERAL_MINT, dollars(1)); + set_price(test, &w, BORROW_MINT, dollars(1)); + + initialize_reserve_with_threshold_and_bonus(test, &w, COLLATERAL_MINT, 8_000, 2_500).succeeds(); + let reserve = test.read::(w.collateral_reserve); + assert_eq!(u16::from(reserve.liquidation_threshold_bps), 8_000); + assert_eq!(u16::from(reserve.liquidation_bonus_bps), 2_500); + + initialize_reserve_with_threshold_and_bonus(test, &w, BORROW_MINT, 9_523, 500).succeeds(); + let reserve = test.read::(w.borrow_reserve); + assert_eq!(u16::from(reserve.liquidation_threshold_bps), 9_523); + assert_eq!(u16::from(reserve.liquidation_bonus_bps), 500); +} + /// Deposits floor the shares minted and redemptions floor the liquidity paid /// out, so a supplier who deposits and redeems over and over, at a size that /// does not divide the exchange rate evenly, can never end up with more than @@ -786,6 +956,27 @@ fn close_obligation_with_debt_is_refused(test: &mut Test) { assert!(u128::from(test.read::(w.obligation).borrowed_principal) > 0); } +/// The market owner sends their one collateral share straight to `vault` +/// with an SPL `transfer_checked` (instruction 12): a donation the program +/// never recorded. The vault holds the borrower's 1,000 units, plus this. +fn donate_owner_share(test: &mut Test, w: &Pdas, vault: Pubkey) { + let mut data = vec![12u8]; + data.extend_from_slice(&1u64.to_le_bytes()); + data.push(DECIMALS); + test.send(Instruction { + program_id: quasar_svm::SPL_TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(OWNER_COLLATERAL_SHARE, false), + AccountMeta::new_readonly(w.collateral_share_mint, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(OWNER, true), + ], + data, + }) + .succeeds() + .has_tokens(vault, 1_000 * UNIT + 1); +} + /// The borrower's collateral vault for the collateral reserve. fn collateral_vault(test: &Test, w: &Pdas) -> Pubkey { test.derive_pda(ObligationVaultPda::seeds( @@ -873,22 +1064,7 @@ fn donated_shares_cannot_keep_the_vault_open(test: &mut Test) { bootstrap_position(test, &w); let vault = collateral_vault(test, &w); - // An SPL `transfer_checked` (instruction 12) of the owner's one share. - let mut data = vec![12u8]; - data.extend_from_slice(&1u64.to_le_bytes()); - data.push(DECIMALS); - test.send(Instruction { - program_id: quasar_svm::SPL_TOKEN_PROGRAM_ID, - accounts: vec![ - AccountMeta::new(OWNER_COLLATERAL_SHARE, false), - AccountMeta::new_readonly(w.collateral_share_mint, false), - AccountMeta::new(vault, false), - AccountMeta::new_readonly(OWNER, true), - ], - data, - }) - .succeeds() - .has_tokens(vault, 1_000 * UNIT + 1); + donate_owner_share(test, &w, vault); withdraw(test, &w, 1_000 * UNIT) .succeeds() @@ -896,62 +1072,273 @@ fn donated_shares_cannot_keep_the_vault_open(test: &mut Test) { .has_tokens(BORROWER_COLLATERAL_SHARE, 1_000 * UNIT + 1); } -/// A seizure that takes every collateral share closes the collateral vault -/// and returns its rent to the obligation's owner, who paid it, not to the -/// liquidator who sent the transaction. -/// -/// At $0.3675 the 1,000 collateral units are worth $367.50, and the close -/// factor caps the repayment at half the $700 debt, $350, whose value plus -/// the 5% bonus is exactly $367.50: the whole deposit. +/// Price at which the capped repayment seizes the whole deposit: at $0.3675 +/// the 1,000 collateral units are worth $367.50, and the close factor caps the +/// repayment at half the $700 debt, $350, whose value plus the 5% bonus is +/// exactly $367.50. +const PRICE_TO_SEIZE_EVERYTHING: i128 = 367_500_000_000_000_000; + +/// A seizure that takes every collateral share closes the collateral vault. +/// Its rent goes into the obligation account, not to any wallet, so +/// liquidation takes no account the borrower controls; the owner's wallet is +/// untouched until `close_obligation` hands it back. #[quasar_test] -fn seizing_all_collateral_closes_the_vault_and_returns_its_rent_to_the_owner(test: &mut Test) { +fn seizing_all_collateral_closes_the_vault_into_the_obligation(test: &mut Test) { let w = base_world(test); bootstrap_position(test, &w); borrow(test, &w, 700 * UNIT).succeeds(); - set_price(test, &w, COLLATERAL_MINT, 367_500_000_000_000_000); + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); let vault = collateral_vault(test, &w); let vault_rent = test.lamports(vault); + let obligation_before = test.lamports(w.obligation); let owner_before = test.lamports(BORROWER); liquidate(test, &w, 350 * UNIT) .succeeds() .is_closed(vault) .has_tokens(LIQUIDATOR_COLLATERAL_SHARE, 1_000 * UNIT) - .has_lamports(BORROWER, owner_before + vault_rent); + .has_lamports(w.obligation, obligation_before + vault_rent); + // The owner's wallet is not even part of the liquidation. + assert_eq!(test.lamports(BORROWER), owner_before); assert_eq!( u64::from(test.read::(w.obligation).deposited_shares), 0 ); } -/// The vault's rent belongs to the owner who paid it, so a liquidator cannot -/// name another account (here the supplier's wallet) as `obligation_owner` to -/// send it elsewhere. +/// Share tokens sent straight to the vault are not recorded in the +/// obligation. A seizure that empties the vault sweeps them to the liquidator +/// with the seized shares, so the vault can still close. The donor is the +/// market owner, whose opening deposit minted one share. #[quasar_test] -fn liquidator_cannot_redirect_the_vault_rent(test: &mut Test) { +fn seizing_all_collateral_sweeps_donated_shares_to_the_liquidator(test: &mut Test) { let w = base_world(test); bootstrap_position(test, &w); borrow(test, &w, 700 * UNIT).succeeds(); - set_price(test, &w, COLLATERAL_MINT, 367_500_000_000_000_000); + let vault = collateral_vault(test, &w); + donate_owner_share(test, &w, vault); - liquidate_with_rent_to(test, &w, SUPPLIER, 350 * UNIT).fails_with(QuasarError::AddressMismatch); - test.send(LiquidateObligationInstruction { - liquidator: LIQUIDATOR, - obligation: w.obligation, - obligation_owner: BORROWER, + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); + let vault_rent = test.lamports(vault); + let obligation_before = test.lamports(w.obligation); + liquidate(test, &w, 350 * UNIT) + .succeeds() + .is_closed(vault) + .has_tokens(LIQUIDATOR_COLLATERAL_SHARE, 1_000 * UNIT + 1) + .has_lamports(w.obligation, obligation_before + vault_rent); +} + +/// A seizure that leaves shares behind leaves the vault open, holding exactly +/// the shares the obligation still records. +#[quasar_test] +fn partial_liquidation_keeps_the_vault_open(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, cents(50)); + let vault = collateral_vault(test, &w); + + liquidate(test, &w, 100 * UNIT).succeeds(); + let seized = test.tokens(LIQUIDATOR_COLLATERAL_SHARE); + assert!(seized > 0); + let remaining = u64::from(test.read::(w.obligation).deposited_shares); + assert_eq!(remaining, 1_000 * UNIT - seized); + assert_eq!(test.tokens(vault), remaining); +} + +/// The vault rent a liquidation left in the obligation returns to the owner +/// when they close it: once the remaining debt is repaid, `close_obligation` +/// pays out the obligation's own rent and the vault's together. quasar-test +/// charges no transaction fee, so the owner's balance rises by exactly both. +#[quasar_test] +fn close_obligation_after_full_liquidation_returns_both_rents_to_the_owner(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); + let vault_rent = test.lamports(collateral_vault(test, &w)); + let obligation_rent = test.lamports(w.obligation); + liquidate(test, &w, 350 * UNIT).succeeds(); + + // The seizure repaid $350 of the $700; the borrower repays the rest. + repay(test, &w, 1_000 * UNIT).succeeds(); + assert_eq!( + u128::from(test.read::(w.obligation).borrowed_principal), + 0 + ); + assert_eq!(test.lamports(w.obligation), obligation_rent + vault_rent); + + let owner_before = test.lamports(BORROWER); + close_obligation(test, &w) + .succeeds() + .is_closed(w.obligation) + .has_lamports(BORROWER, owner_before + obligation_rent + vault_rent); +} + +/// An owner may liquidate their own unhealthy position: they repay their own +/// debt and take their own collateral at the bonus. Pointless economically, +/// but not refused. +#[quasar_test] +fn owner_can_liquidate_their_own_obligation(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, cents(50)); + assert_eq!(test.tokens(BORROWER_COLLATERAL_SHARE), 0); + + liquidate_as( + test, + &w, + BORROWER, + BORROWER_BORROW, + BORROWER_COLLATERAL_SHARE, + 100 * UNIT, + ) + .succeeds() + .has_tokens(BORROWER_BORROW, 600 * UNIT); + let seized = test.tokens(BORROWER_COLLATERAL_SHARE); + assert!(seized > 0); + assert_eq!( + u64::from(test.read::(w.obligation).deposited_shares), + 1_000 * UNIT - seized + ); +} + +/// An owner may also liquidate their own position down to nothing: the +/// seizure empties the vault, which closes into the obligation account, while +/// the owner signs as the liquidator. The owner gets every share back and the +/// vault's rent waits in the obligation. +#[quasar_test] +fn owner_can_liquidate_their_own_obligation_to_empty(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); + let vault = collateral_vault(test, &w); + let vault_rent = test.lamports(vault); + let obligation_before = test.lamports(w.obligation); + assert_eq!(test.tokens(BORROWER_COLLATERAL_SHARE), 0); + + liquidate_as( + test, + &w, + BORROWER, + BORROWER_BORROW, + BORROWER_COLLATERAL_SHARE, + 350 * UNIT, + ) + .succeeds() + .is_closed(vault) + .has_tokens(BORROWER_BORROW, 350 * UNIT) + .has_tokens(BORROWER_COLLATERAL_SHARE, 1_000 * UNIT) + .has_lamports(w.obligation, obligation_before + vault_rent); + assert_eq!( + u64::from(test.read::(w.obligation).deposited_shares), + 0 + ); +} + +/// A liquidation that takes all the collateral and half the debt leaves the +/// obligation holding debt and nothing else, and `close_obligation` refuses it +/// until that debt is repaid. +#[quasar_test] +fn close_obligation_refused_while_debt_remains_after_full_liquidation(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); + liquidate(test, &w, 350 * UNIT).succeeds(); + let obligation = test.read::(w.obligation); + assert_eq!(u64::from(obligation.deposited_shares), 0); + assert!(u128::from(obligation.borrowed_principal) > 0); + + close_obligation(test, &w).fails_with(LendingError::ObligationNotEmpty); + assert!(test.lamports(w.obligation) > 0); +} + +/// After a liquidation closes the vault, the owner can post collateral again: +/// the deposit recreates the vault, paid for by the owner, and the old vault's +/// rent stays in the obligation. The owner has no shares left, so the +/// liquidator hands 600 back with an SPL `transfer_checked` (instruction 12). +#[quasar_test] +fn redeposit_after_full_liquidation_recreates_the_vault(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + borrow(test, &w, 700 * UNIT).succeeds(); + set_price(test, &w, COLLATERAL_MINT, PRICE_TO_SEIZE_EVERYTHING); + let vault = collateral_vault(test, &w); + let vault_rent = test.lamports(vault); + let obligation_rent = test.lamports(w.obligation); + liquidate(test, &w, 350 * UNIT).succeeds().is_closed(vault); + + let mut data = vec![12u8]; + data.extend_from_slice(&(600 * UNIT).to_le_bytes()); + data.push(DECIMALS); + test.send(Instruction { + program_id: quasar_svm::SPL_TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(LIQUIDATOR_COLLATERAL_SHARE, false), + AccountMeta::new_readonly(w.collateral_share_mint, false), + AccountMeta::new(BORROWER_COLLATERAL_SHARE, false), + AccountMeta::new_readonly(LIQUIDATOR, true), + ], + data, + }) + .succeeds() + .has_tokens(BORROWER_COLLATERAL_SHARE, 600 * UNIT); + + test.send(DepositObligationCollateralInstruction { + owner: BORROWER, lending_market: w.market, - collateral_reserve: w.collateral_reserve, - collateral_price: w.collateral_price, + reserve: w.collateral_reserve, share_mint: w.collateral_share_mint, - liquidator_collateral: LIQUIDATOR_COLLATERAL_SHARE, - borrow_reserve: w.borrow_reserve, - borrow_price: w.borrow_price, - liquidity_mint: BORROW_MINT, - liquidity_vault: w.borrow_vault, - liquidator_liquidity: LIQUIDATOR_BORROW, - amount: 350 * UNIT, + owner_share: BORROWER_COLLATERAL_SHARE, + shares: 600 * UNIT, }) - .succeeds(); + .succeeds() + .has_tokens(vault, 600 * UNIT) + .has_tokens(BORROWER_COLLATERAL_SHARE, 0) + .has_lamports(vault, vault_rent) + .has_lamports(w.obligation, obligation_rent + vault_rent); + assert_eq!( + u64::from(test.read::(w.obligation).deposited_shares), + 600 * UNIT + ); +} + +/// With no debt the withdraw handler ignores the borrow reserve and borrow +/// price accounts, so a debt-free borrower can pass any: here a reserve the +/// obligation never borrowed from and a feed that is not that reserve's. With +/// debt, the same accounts are refused. +#[quasar_test] +fn debt_free_withdraw_ignores_the_borrow_accounts(test: &mut Test) { + let w = base_world(test); + bootstrap_position(test, &w); + // An unrelated reserve, for the quote mint. + set_price(test, &w, QUOTE_MINT, dollars(1)); + initialize_reserve(test, &w, QUOTE_MINT); + let quote_reserve = test.derive_pda(Reserve::seeds(&w.market, "E_MINT)); + + let withdraw_with_unrelated_borrow_accounts = |test: &mut Test, shares: u64| { + test.send(WithdrawObligationCollateralInstruction { + owner: BORROWER, + lending_market: w.market, + collateral_reserve: w.collateral_reserve, + collateral_price: w.collateral_price, + share_mint: w.collateral_share_mint, + borrow_reserve: quote_reserve, + borrow_price: w.borrow_price, + owner_share: BORROWER_COLLATERAL_SHARE, + shares, + }) + }; + + withdraw_with_unrelated_borrow_accounts(test, 400 * UNIT) + .succeeds() + .has_tokens(BORROWER_COLLATERAL_SHARE, 400 * UNIT); + + borrow(test, &w, 100 * UNIT).succeeds(); + withdraw_with_unrelated_borrow_accounts(test, UNIT).fails_with(LendingError::WrongReserve); } /// The scenarios below move the slot and the Clock's timestamp independently: diff --git a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs index de005084..d8c93fd4 100644 --- a/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor-v1/programs/order-book/tests/test_order_book.rs @@ -3587,3 +3587,73 @@ fn close_market_user_refuses_a_non_owner() { assert_fails_with(&error, order_book::errors::ErrorCode::Unauthorized); assert!(account_is_open(&sc.svm, &sc.seller_market_user)); } + +#[test] +fn evicted_order_and_its_owners_market_user_close_after_settling() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let evicted = &fillers[0]; + + // A better bid evicts the worst one, which eviction stamps Cancelled and + // refunds through its owner's unsettled balance. + place_bid( + &mut sc, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, evicted.market_user)], + ) + .unwrap(); + let worst_order = order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID); + let (_, status) = read_order_fill_and_status(&sc.svm, &worst_order); + assert_eq!(status, ORDER_STATUS_CANCELLED); + + // The evicted owner's other bids (IDs 2 through ORDERS_PER_FILLER) still + // rest, so they cancel those to have nothing open, then settle every + // refund to have nothing owed. + for order_id in WORST_BID_ORDER_ID + 1..=ORDERS_PER_FILLER { + let cancel_ix = build_cancel_order_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + order_id, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![cancel_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + } + let settle_ix = build_settle_funds_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + evicted.base_ata, + evicted.quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + assert_eq!(read_open_order_count(&sc.svm, &evicted.market_user), 0); + assert_eq!(read_user_unsettled(&sc.svm, &evicted.market_user), (0, 0)); + + // The evicted order closes to its owner, then the owner's MarketUser. + let close_order_ix = build_close_order_ix(&sc, &evicted.keypair.pubkey(), WORST_BID_ORDER_ID); + assert_close_returns_rent(&mut sc.svm, close_order_ix, &worst_order, &evicted.keypair); + let close_user_ix = + build_close_market_user_ix(&sc, &evicted.keypair.pubkey(), evicted.market_user); + assert_close_returns_rent( + &mut sc.svm, + close_user_ix, + &evicted.market_user, + &evicted.keypair, + ); +} diff --git a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs index 9a6b6069..e32ac495 100644 --- a/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs +++ b/finance/order-book/anchor/programs/order-book/tests/test_order_book.rs @@ -3595,3 +3595,73 @@ fn close_market_user_refuses_a_non_owner() { assert_fails_with(&error, order_book::errors::ErrorCode::Unauthorized); assert!(account_is_open(&sc.svm, &sc.seller_market_user)); } + +#[test] +fn evicted_order_and_its_owners_market_user_close_after_settling() { + let mut sc = full_setup(); + initialize_market_and_users(&mut sc); + let fillers = fill_bid_side(&mut sc); + let newcomer = create_trader(&mut sc); + let evicted = &fillers[0]; + + // A better bid evicts the worst one, which eviction stamps Cancelled and + // refunds through its owner's unsettled balance. + place_bid( + &mut sc, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[(WORST_BID_ORDER_ID, evicted.market_user)], + ) + .unwrap(); + let worst_order = order_pda(&sc.program_id, &sc.market, WORST_BID_ORDER_ID); + let (_, status) = read_order_fill_and_status(&sc.svm, &worst_order); + assert_eq!(status, ORDER_STATUS_CANCELLED); + + // The evicted owner's other bids (IDs 2 through ORDERS_PER_FILLER) still + // rest, so they cancel those to have nothing open, then settle every + // refund to have nothing owed. + for order_id in WORST_BID_ORDER_ID + 1..=ORDERS_PER_FILLER { + let cancel_ix = build_cancel_order_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + order_id, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![cancel_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + } + let settle_ix = build_settle_funds_ix( + &sc, + &evicted.keypair.pubkey(), + evicted.market_user, + evicted.base_ata, + evicted.quote_ata, + ); + send_transaction_from_instructions( + &mut sc.svm, + vec![settle_ix], + &[&evicted.keypair], + &evicted.keypair.pubkey(), + ) + .unwrap(); + assert_eq!(read_open_order_count(&sc.svm, &evicted.market_user), 0); + assert_eq!(read_user_unsettled(&sc.svm, &evicted.market_user), (0, 0)); + + // The evicted order closes to its owner, then the owner's MarketUser. + let close_order_ix = build_close_order_ix(&sc, &evicted.keypair.pubkey(), WORST_BID_ORDER_ID); + assert_close_returns_rent(&mut sc.svm, close_order_ix, &worst_order, &evicted.keypair); + let close_user_ix = + build_close_market_user_ix(&sc, &evicted.keypair.pubkey(), evicted.market_user); + assert_close_returns_rent( + &mut sc.svm, + close_user_ix, + &evicted.market_user, + &evicted.keypair, + ); +} diff --git a/finance/order-book/quasar/src/tests.rs b/finance/order-book/quasar/src/tests.rs index cb703db4..3777d916 100644 --- a/finance/order-book/quasar/src/tests.rs +++ b/finance/order-book/quasar/src/tests.rs @@ -1229,3 +1229,56 @@ fn close_market_user_refuses_a_non_owner(test: &mut Test) { let maker_market_user = test.derive_pda(MarketUser::seeds(&market, &MAKER)); assert_eq!(test.read::(maker_market_user).owner, MAKER); } + +#[quasar_test] +fn evicted_order_and_its_owners_market_user_close_after_settling(test: &mut Test) { + let market = init_market(test); + let fillers = fill_bid_side(test, market); + let newcomer = create_trader(test, market, 100); + let evicted = &fillers[0]; + let worst_order = test.derive_pda(Order::seeds(&market, WORST_BID_ORDER_ID)); + + // A better bid evicts the worst one, which eviction stamps Cancelled and + // refunds through its owner's unsettled balance. + place_bid( + test, + market, + &newcomer, + ORDERS_PER_SIDE + 1, + EVICTION_BETTER_BID_PRICE, + &[worst_order, evicted.market_user], + ) + .succeeds(); + assert_eq!( + test.read::(worst_order).status, + OrderStatus::Cancelled as u8 + ); + + // The evicted owner's other bids (IDs 2 through ORDERS_PER_FILLER) still + // rest, so they cancel those to have nothing open, then settle every + // refund to have nothing owed. + for order_id in WORST_BID_ORDER_ID + 1..=ORDERS_PER_FILLER { + test.send(CancelOrderInstruction { + market, + order_book: ORDER_BOOK, + order_order_id_seed: order_id, + owner: evicted.owner, + }) + .succeeds(); + } + settle_funds(test, market, evicted.owner, evicted.base, evicted.quote).succeeds(); + let evicted_user = test.read::(evicted.market_user); + assert_eq!(evicted_user.open_orders_len, 0); + assert_eq!(u64::from(evicted_user.unsettled_base), 0); + assert_eq!(u64::from(evicted_user.unsettled_quote), 0); + + // The evicted order closes to its owner, then the owner's MarketUser. + close_order_and_assert_rent_returned(test, market, evicted.owner, WORST_BID_ORDER_ID); + let user_rent = test.lamports(evicted.market_user); + let owner_before = test.lamports(evicted.owner); + let close_ix = close_market_user(test, market, evicted.owner, evicted.owner); + test.send(close_ix) + .succeeds() + .is_closed(evicted.market_user) + .has_lamports(evicted.owner, owner_before + user_rent); +}