diff --git a/script/interfaces/ICatFactory.sol b/script/interfaces/ICatFactory.sol index b024085..d27d256 100644 --- a/script/interfaces/ICatFactory.sol +++ b/script/interfaces/ICatFactory.sol @@ -8,23 +8,25 @@ interface ICatFactory { // ============================================================================================ struct DeployParams { - address borrowToken; - address collateralToken; - address priceOracle; + address borrow_token; + address collateral_token; + address price_oracle; address management; - address performanceFeeRecipient; - uint256 minimumDebt; - uint256 minimumCollateralRatio; - uint256 upfrontInterestPeriod; - uint256 interestRateAdjCooldown; - uint256 redemptionMinimumPriceBufferPercentage; - uint256 redemptionStartingPriceBufferPercentage; - uint256 redemptionReKickStartingPriceBufferPercentage; - uint256 liquidationMinimumPriceBufferPercentage; - uint256 liquidationStartingPriceBufferPercentage; - uint256 stepDuration; - uint256 stepDecayRate; - uint256 auctionLength; + address performance_fee_recipient; + uint256 minimum_debt; + uint256 safe_collateral_ratio; + uint256 minimum_collateral_ratio; + uint256 max_penalty_collateral_ratio; + uint256 min_liquidation_fee; + uint256 max_liquidation_fee; + uint256 upfront_interest_period; + uint256 interest_rate_adj_cooldown; + uint256 minimum_price_buffer_percentage; + uint256 starting_price_buffer_percentage; + uint256 re_kick_starting_price_buffer_percentage; + uint256 step_duration; + uint256 step_decay_rate; + uint256 auction_length; bytes32 salt; } diff --git a/src/auction.vy b/src/auction.vy index 57546a9..4b53864 100644 --- a/src/auction.vy +++ b/src/auction.vy @@ -50,7 +50,6 @@ struct AuctionInfo: minimum_price: uint256 receiver: address surplus_receiver: address - is_liquidation: bool struct InitializeParams: @@ -94,7 +93,6 @@ step_decay_rate: public(uint256) auction_length: public(uint256) # Accounting -liquidation_auctions: public(uint256) # count of active liquidation auctions auctions: public(HashMap[uint256, AuctionInfo]) # auction ID --> AuctionInfo @@ -236,16 +234,6 @@ def is_active(auction_id: uint256) -> bool: return self._is_active(auction) -@external -@view -def is_ongoing_liquidation_auction() -> bool: - """ - @notice Check if there's at least one ongoing liquidation auction - @return Whether there's at least one ongoing liquidation auction - """ - return self.liquidation_auctions > 0 - - # ============================================================================================ # Kick # ============================================================================================ @@ -260,7 +248,6 @@ def kick( minimum_price: uint256, receiver: address, surplus_receiver: address, - is_liquidation: bool, ): """ @notice Kick off an auction @@ -273,7 +260,6 @@ def kick( @param minimum_price The minimum price for the auction, WAD scaled in buy token @param receiver The address that will receive the auction proceeds @param surplus_receiver The address that will receive any surplus proceeds above maximum_amount - @param is_liquidation Whether this auction is selling liquidated collateral """ # Make sure caller is Papi assert msg.sender == self.papi, "!papi" @@ -299,10 +285,6 @@ def kick( # Make sure auction is not already active assert not self._is_active(auction), "active" - # If liquidation auction, increment counter - if is_liquidation: - self.liquidation_auctions += 1 - # Update storage self.auctions[auction_id] = AuctionInfo( kick_timestamp=block.timestamp, @@ -314,7 +296,6 @@ def kick( minimum_price=minimum_price, receiver=receiver, surplus_receiver=surplus_receiver, - is_liquidation=is_liquidation, ) # Pull the tokens from Papi @@ -417,16 +398,12 @@ def take( # Reset kick timestamp to mark auction as inactive auction.kick_timestamp = 0 - # If it was a liquidation auction, decrement counter - if auction.is_liquidation: - self.liquidation_auctions -= 1 - # Send the token being sold to the take receiver assert extcall self.sell_token.transfer(receiver, take_amount, default_return_value=True) # If the caller provided data, perform the callback if len(data) != 0: - extcall ITaker(receiver).auctionTakeCallback( + extcall ITaker(receiver).takeCallback( auction_id, msg.sender, take_amount, @@ -437,30 +414,24 @@ def take( # Cache the buy token contract buy_token: IERC20 = self.buy_token - # If liquidation auction, all proceeds goes to the Lender contract. - # Otherwise, make sure the receiver does not get more than the maximum and transfer the surplus to the surplus receiver - if auction.is_liquidation: - # Liquidation: all to the Lender contract + # How much the receiver still needs + receiver_remaining: uint256 = auction.maximum_amount - auction.amount_received + + # If the bought amount is less than the receiver's maximum amount, transfer him all of it. + # Otherwise, cover the receiver first, then transfer the surplus to the surplus receiver + if needed_amount <= receiver_remaining: + # Entire amount to the receiver + auction.amount_received += needed_amount assert extcall buy_token.transferFrom(msg.sender, auction.receiver, needed_amount, default_return_value=True) else: - # How much the receiver still needs - receiver_remaining: uint256 = auction.maximum_amount - auction.amount_received - - # If the bought amount is less than the receiver's maximum amount, transfer him all of it. - # Otherwise, cover the receiver first, then transfer the surplus to the surplus receiver - if needed_amount <= receiver_remaining: - # Entire amount to the receiver - auction.amount_received += needed_amount - assert extcall buy_token.transferFrom(msg.sender, auction.receiver, needed_amount, default_return_value=True) - else: - # Cover the receiver first - if receiver_remaining > 0: - auction.amount_received = auction.maximum_amount - assert extcall buy_token.transferFrom(msg.sender, auction.receiver, receiver_remaining, default_return_value=True) - - # Transfer the surplus to the Lender contract - surplus: uint256 = needed_amount - receiver_remaining - assert extcall buy_token.transferFrom(msg.sender, auction.surplus_receiver, surplus, default_return_value=True) + # Cover the receiver first + if receiver_remaining > 0: + auction.amount_received = auction.maximum_amount + assert extcall buy_token.transferFrom(msg.sender, auction.receiver, receiver_remaining, default_return_value=True) + + # Transfer the surplus to the surplus receiver + surplus: uint256 = needed_amount - receiver_remaining + assert extcall buy_token.transferFrom(msg.sender, auction.surplus_receiver, surplus, default_return_value=True) # Update storage. No need to worry about re-entrancy since non-reentrant pragma is enabled self.auctions[auction_id] = auction diff --git a/src/dutch_desk.vy b/src/dutch_desk.vy index 9f84353..06fcd5e 100644 --- a/src/dutch_desk.vy +++ b/src/dutch_desk.vy @@ -4,7 +4,7 @@ @title Dutch Desk @license MIT @author Flex -@notice Handles liquidations and redemptions through Dutch Auctions +@notice Handles redemptions through Dutch Auctions """ from ethereum.ercs import IERC20 @@ -38,11 +38,9 @@ collateral_token: public(IERC20) collateral_token_precision: public(uint256) # Parameters -redemption_minimum_price_buffer_percentage: public(uint256) -redemption_starting_price_buffer_percentage: public(uint256) -redemption_re_kick_starting_price_buffer_percentage: public(uint256) -liquidation_minimum_price_buffer_percentage: public(uint256) -liquidation_starting_price_buffer_percentage: public(uint256) +minimum_price_buffer_percentage: public(uint256) +starting_price_buffer_percentage: public(uint256) +re_kick_starting_price_buffer_percentage: public(uint256) # Accounting nonce: public(uint256) @@ -60,11 +58,9 @@ struct InitializeParams: auction: address borrow_token: address collateral_token: address - redemption_minimum_price_buffer_percentage: uint256 - redemption_starting_price_buffer_percentage: uint256 - redemption_re_kick_starting_price_buffer_percentage: uint256 - liquidation_minimum_price_buffer_percentage: uint256 - liquidation_starting_price_buffer_percentage: uint256 + minimum_price_buffer_percentage: uint256 + starting_price_buffer_percentage: uint256 + re_kick_starting_price_buffer_percentage: uint256 # ============================================================================================ @@ -91,11 +87,9 @@ def initialize(params: InitializeParams): self.collateral_token = IERC20(params.collateral_token) # Set parameters - self.redemption_minimum_price_buffer_percentage = params.redemption_minimum_price_buffer_percentage - self.redemption_starting_price_buffer_percentage = params.redemption_starting_price_buffer_percentage - self.redemption_re_kick_starting_price_buffer_percentage = params.redemption_re_kick_starting_price_buffer_percentage - self.liquidation_minimum_price_buffer_percentage = params.liquidation_minimum_price_buffer_percentage - self.liquidation_starting_price_buffer_percentage = params.liquidation_starting_price_buffer_percentage + self.minimum_price_buffer_percentage = params.minimum_price_buffer_percentage + self.starting_price_buffer_percentage = params.starting_price_buffer_percentage + self.re_kick_starting_price_buffer_percentage = params.re_kick_starting_price_buffer_percentage # Get collateral token decimals collateral_token_decimals: uint256 = convert(staticcall IERC20Detailed(params.collateral_token).decimals(), uint256) @@ -113,21 +107,14 @@ def initialize(params: InitializeParams): @external -def kick( - kick_amount: uint256, - maximum_amount: uint256 = 0, - receiver: address = empty(address), - is_liquidation: bool = True, -): +def kick(kick_amount: uint256, maximum_amount: uint256, receiver: address): """ @notice Kicks an auction of collateral tokens for borrow tokens @dev Only callable by the Trove Manager contract - @dev Will use the Lender contract as receiver of auction proceeds if `receiver` is zero address @dev Caller must approve this contract to transfer collateral tokens on its behalf before calling @param kick_amount Amount of collateral tokens to auction @param maximum_amount The maximum amount borrow tokens to be received @param receiver Address to receive the auction proceeds in borrow tokens - @param is_liquidation Whether this auction is for liquidated collateral """ # Make sure caller is the Trove Manager contract assert msg.sender == self.trove_manager, "!trove_manager" @@ -139,7 +126,10 @@ def kick( # Get the starting and minimum prices starting_price: uint256 = 0 minimum_price: uint256 = 0 - starting_price, minimum_price = self._get_prices(kick_amount, is_liquidation) + starting_price, minimum_price = self._get_prices( + kick_amount, + self.starting_price_buffer_percentage, + ) # Use the nonce as auction identifier auction_id: uint256 = self.nonce @@ -147,7 +137,7 @@ def kick( # Increment the nonce self.nonce = auction_id + 1 - # Pull the collateral tokens from the Trove Manager + # Pull the collateral tokens from the Trove Manager contract assert extcall self.collateral_token.transferFrom(self.trove_manager, self, kick_amount, default_return_value=True) # Kick the auction @@ -157,9 +147,8 @@ def kick( maximum_amount, starting_price, minimum_price, - receiver if receiver != empty(address) else self.lender, + receiver, self.lender, # surplus receiver - is_liquidation, ) @@ -169,23 +158,22 @@ def re_kick(auction_id: uint256): @notice Re-kick an inactive auction with new starting and minimum prices @dev Will revert if the auction is not kickable @dev An auction may need to be re-kicked if its price has fallen below its minimum price - @dev Uses a higher starting price buffer percentage to allow for takers to regroup + @dev May use a higher starting price buffer percentage to allow for takers to regroup @dev Does not set the receiver nor transfer collateral as those are already ready in the auction @param auction_id Identifier of the auction to re-kick """ # Cache the Auction contract auction: IAuction = self.auction - # Cache the auction info + # Get the auction info auction_info: IAuction.AuctionInfo = staticcall auction.auctions(auction_id) # Get new starting and minimum prices starting_price: uint256 = 0 minimum_price: uint256 = 0 starting_price, minimum_price = self._get_prices( - auction_info.currentAmount, - auction_info.isLiquidation, - True, # is_rekick + auction_info.current_amount, + self.re_kick_starting_price_buffer_percentage, ) # Re-kick with new prices @@ -199,32 +187,14 @@ def re_kick(auction_id: uint256): @internal @view -def _get_prices( - kick_amount: uint256, - is_liquidation: bool, - is_rekick: bool = False, -) -> (uint256, uint256): +def _get_prices(kick_amount: uint256, starting_price_buffer_pct: uint256) -> (uint256, uint256): """ @notice Gets the starting and minimum prices for an auction @param kick_amount Amount of collateral tokens to auction - @param is_liquidation Whether this is a liquidation auction - @param is_rekick Whether this is a re-kick of an existing auction + @param starting_price_buffer_pct The buffer percentage to apply to the starting price @return starting_price The calculated starting price @return minimum_price The calculated minimum price """ - # Determine the minimum and starting price buffer percentages - minimum_price_buffer_pct: uint256 = 0 - starting_price_buffer_pct: uint256 = 0 - if is_liquidation: - minimum_price_buffer_pct = self.liquidation_minimum_price_buffer_percentage - starting_price_buffer_pct = self.liquidation_starting_price_buffer_percentage - else: - minimum_price_buffer_pct = self.redemption_minimum_price_buffer_percentage - if is_rekick: - starting_price_buffer_pct = self.redemption_re_kick_starting_price_buffer_percentage - else: - starting_price_buffer_pct = self.redemption_starting_price_buffer_percentage - # Get the collateral price collateral_price: uint256 = staticcall self.price_oracle.get_price(False) # price in WAD format @@ -234,6 +204,6 @@ def _get_prices( # Calculate the minimum price with buffer to the collateral price # Minimum price is per token and is scaled to WAD - minimum_price: uint256 = collateral_price * minimum_price_buffer_pct // _WAD + minimum_price: uint256 = collateral_price * self.minimum_price_buffer_percentage // _WAD return starting_price, minimum_price \ No newline at end of file diff --git a/src/factory.vy b/src/factory.vy index dbdf6ae..4807f2c 100644 --- a/src/factory.vy +++ b/src/factory.vy @@ -42,14 +42,16 @@ struct DeployParams: management: address # address of the management performance_fee_recipient: address # address of the performance fee recipient minimum_debt: uint256 # minimum borrowable amount, e.g., `500 * borrow_token_precision` for 500 tokens - minimum_collateral_ratio: uint256 # minimum CR to avoid liquidation, e.g., `110 * one_pct` for 110% + safe_collateral_ratio: uint256 # target CR after partial liquidation, e.g., `115` for 115%. must be greater than `100% + max_liquidation_fee` to avoid underflow in the safe CR calculation + minimum_collateral_ratio: uint256 # minimum CR to avoid liquidation, e.g., `110` for 110% + max_penalty_collateral_ratio: uint256 # CR at which max liquidation fee applies, e.g., `105` for 105% + min_liquidation_fee: uint256 # minimum liquidation fee in hundredths of a percent, e.g., `50` for 0.5% + max_liquidation_fee: uint256 # maximum liquidation fee in hundredths of a percent, e.g., `500` for 5% upfront_interest_period: uint256 # duration for upfront interest charges, e.g., `7 * 24 * 60 * 60` for 7 days interest_rate_adj_cooldown: uint256 # cooldown between rate adjustments, e.g., `7 * 24 * 60 * 60` for 7 days - redemption_minimum_price_buffer_percentage: uint256 # redemption auction minimum price buffer, e.g. `WAD - 5 * 10 ** 16` for 5% below oracle price - redemption_starting_price_buffer_percentage: uint256 # redemption auction starting price buffer, e.g. `WAD + 1 * 10 ** 16` for 1% above oracle price. must be >= max oracle deviation from market price to ensure the starting auction price is always above market price, preventing value extraction from oracle lag - redemption_re_kick_starting_price_buffer_percentage: uint256 # redemption auction re-kick price buffer, e.g. `WAD + 5 * 10 ** 16` for 5% above oracle price - liquidation_minimum_price_buffer_percentage: uint256 # liquidation auction minimum price buffer, e.g. `WAD - 10 * 10 ** 16` for 10% below oracle price - liquidation_starting_price_buffer_percentage: uint256 # liquidation auction starting price buffer, e.g., `WAD - 1 * 10 ** 16` for 1% below oracle price + minimum_price_buffer_percentage: uint256 # auction minimum price buffer, e.g. `WAD - 5 * 10 ** 16` for 5% below oracle price + starting_price_buffer_percentage: uint256 # auction starting price buffer, e.g. `WAD + 1 * 10 ** 16` for 1% above oracle price. must be >= max oracle deviation from market price to ensure the starting auction price is always above market price, preventing value extraction from oracle lag + re_kick_starting_price_buffer_percentage: uint256 # auction re-kick price buffer, e.g. `WAD + 5 * 10 ** 16` for 5% above oracle price step_duration: uint256 # duration of each price step, e.g., `60` for price change every minute step_decay_rate: uint256 # decay rate per step, e.g., `50` for 0.5% decrease per step auction_length: uint256 # total auction duration in seconds, e.g., `86400` for 1 day @@ -71,10 +73,6 @@ LENDER_FACTORY: public(immutable(ILenderFactory)) # Version VERSION: public(constant(String[28])) = "1.0.0" -# Utils -_WAD: constant(uint256) = 10 ** 18 -_MAX_TOKEN_DECIMALS: constant(uint256) = 18 - # ============================================================================================ # Constructor @@ -120,17 +118,6 @@ def deploy(params: DeployParams) -> (address, address, address, address, address @return auction Address of the deployed Auction contract @return lender Address of the deployed Lender contract """ - # Make sure borrow and collateral tokens are different - assert params.borrow_token != params.collateral_token, "!tokens" - - # Borrow token cannot have more than 18 decimals - borrow_token_decimals: uint256 = convert(staticcall IERC20Detailed(params.borrow_token).decimals(), uint256) - assert borrow_token_decimals <= _MAX_TOKEN_DECIMALS, "!borrow_token_decimals" - - # Collateral token cannot have more than 18 decimals - collateral_token_decimals: uint256 = convert(staticcall IERC20Detailed(params.collateral_token).decimals(), uint256) - assert collateral_token_decimals <= _MAX_TOKEN_DECIMALS, "!collateral_token_decimals" - # Compute the salt value salt: bytes32 = keccak256(abi_encode(msg.sender, params.salt, params.collateral_token, params.borrow_token)) @@ -154,7 +141,6 @@ def deploy(params: DeployParams) -> (address, address, address, address, address # Deploy the Lender contract via the Lender Factory lender: address = extcall LENDER_FACTORY.deploy( params.borrow_token, - auction, trove_manager, params.management, params.performance_fee_recipient, @@ -170,7 +156,11 @@ def deploy(params: DeployParams) -> (address, address, address, address, address borrow_token=params.borrow_token, collateral_token=params.collateral_token, minimum_debt=params.minimum_debt, + safe_collateral_ratio=params.safe_collateral_ratio, minimum_collateral_ratio=params.minimum_collateral_ratio, + max_penalty_collateral_ratio=params.max_penalty_collateral_ratio, + min_liquidation_fee=params.min_liquidation_fee, + max_liquidation_fee=params.max_liquidation_fee, upfront_interest_period=params.upfront_interest_period, interest_rate_adj_cooldown=params.interest_rate_adj_cooldown, )) @@ -186,11 +176,9 @@ def deploy(params: DeployParams) -> (address, address, address, address, address auction=auction, borrow_token=params.borrow_token, collateral_token=params.collateral_token, - redemption_minimum_price_buffer_percentage=params.redemption_minimum_price_buffer_percentage, - redemption_starting_price_buffer_percentage=params.redemption_starting_price_buffer_percentage, - redemption_re_kick_starting_price_buffer_percentage=params.redemption_re_kick_starting_price_buffer_percentage, - liquidation_minimum_price_buffer_percentage=params.liquidation_minimum_price_buffer_percentage, - liquidation_starting_price_buffer_percentage=params.liquidation_starting_price_buffer_percentage, + minimum_price_buffer_percentage=params.minimum_price_buffer_percentage, + starting_price_buffer_percentage=params.starting_price_buffer_percentage, + re_kick_starting_price_buffer_percentage=params.re_kick_starting_price_buffer_percentage, )) # Initialize the Auction contract diff --git a/src/interfaces/IAuction.vyi b/src/interfaces/IAuction.vyi index 9d27dfd..998d5a5 100644 --- a/src/interfaces/IAuction.vyi +++ b/src/interfaces/IAuction.vyi @@ -7,16 +7,15 @@ struct AuctionInfo: - kickTimestamp: uint256 - initialAmount: uint256 - currentAmount: uint256 - maximumAmount: uint256 - amountReceived: uint256 - startingPrice: uint256 - minimumPrice: uint256 + kick_timestamp: uint256 + initial_amount: uint256 + current_amount: uint256 + maximum_amount: uint256 + amount_received: uint256 + starting_price: uint256 + minimum_price: uint256 receiver: address - surplusReceiver: address - isLiquidation: bool + surplus_receiver: address struct InitializeParams: @@ -63,7 +62,6 @@ def kick( minimum_price: uint256, receiver: address, surplus_receiver: address, - is_liquidation: bool, ): ... diff --git a/src/interfaces/IDutchDesk.vyi b/src/interfaces/IDutchDesk.vyi index 18f0e60..3b56164 100644 --- a/src/interfaces/IDutchDesk.vyi +++ b/src/interfaces/IDutchDesk.vyi @@ -13,11 +13,9 @@ struct InitializeParams: auction: address borrow_token: address collateral_token: address - redemption_minimum_price_buffer_percentage: uint256 - redemption_starting_price_buffer_percentage: uint256 - redemption_re_kick_starting_price_buffer_percentage: uint256 - liquidation_minimum_price_buffer_percentage: uint256 - liquidation_starting_price_buffer_percentage: uint256 + minimum_price_buffer_percentage: uint256 + starting_price_buffer_percentage: uint256 + re_kick_starting_price_buffer_percentage: uint256 # ============================================================================================ @@ -36,5 +34,5 @@ def initialize(params: InitializeParams): @external -def kick(amount: uint256, maximum_amount: uint256 = 0, receiver: address = empty(address), is_liquidation: bool = True): +def kick(amount: uint256, maximum_amount: uint256, receiver: address): ... \ No newline at end of file diff --git a/src/interfaces/ILenderFactory.vyi b/src/interfaces/ILenderFactory.vyi index 5e60fe7..827bec7 100644 --- a/src/interfaces/ILenderFactory.vyi +++ b/src/interfaces/ILenderFactory.vyi @@ -7,5 +7,11 @@ @external -def deploy(_asset: address, _auction: address, _troveManager: address, _management: address, _performanceFeeRecipient: address, name: String[77]) -> address: +def deploy( + _asset: address, + _troveManager: address, + _management: address, + _performanceFeeRecipient: address, + name: String[77], +) -> address: ... \ No newline at end of file diff --git a/src/interfaces/ITaker.vyi b/src/interfaces/ITaker.vyi index cc8811f..a1b75f0 100644 --- a/src/interfaces/ITaker.vyi +++ b/src/interfaces/ITaker.vyi @@ -2,11 +2,11 @@ @external -def auctionTakeCallback( +def takeCallback( auction_id: uint256, taker: address, amount_taken: uint256, - want_amount: uint256, + needed_amount: uint256, data: Bytes[10**5], ): ... \ No newline at end of file diff --git a/src/interfaces/ITroveManager.vyi b/src/interfaces/ITroveManager.vyi index 3f65c75..74926c7 100644 --- a/src/interfaces/ITroveManager.vyi +++ b/src/interfaces/ITroveManager.vyi @@ -37,7 +37,11 @@ struct InitializeParams: borrow_token: address collateral_token: address minimum_debt: uint256 + safe_collateral_ratio: uint256 minimum_collateral_ratio: uint256 + max_penalty_collateral_ratio: uint256 + min_liquidation_fee: uint256 + max_liquidation_fee: uint256 upfront_interest_period: uint256 interest_rate_adj_cooldown: uint256 diff --git a/src/lender/Lender.sol b/src/lender/Lender.sol index 4bda8aa..ae8ab1a 100644 --- a/src/lender/Lender.sol +++ b/src/lender/Lender.sol @@ -5,7 +5,6 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {BaseHooks, ERC20} from "@periphery/Bases/Hooks/BaseHooks.sol"; import {BaseStrategy} from "@tokenized-strategy/BaseStrategy.sol"; -import {IAuction} from "./interfaces/IAuction.sol"; import {ITroveManager} from "./interfaces/ITroveManager.sol"; /// @title Lender @@ -27,9 +26,6 @@ contract Lender is BaseHooks { // Constants // ============================================================================================ - /// @notice Auction contract - IAuction public immutable AUCTION; - /// @notice TroveManager contract ITroveManager public immutable TROVE_MANAGER; @@ -50,17 +46,14 @@ contract Lender is BaseHooks { /// @notice Constructor /// @param _asset The address of the borrow token - /// @param _auction The address of the Auction contract /// @param _troveManager The address of the TroveManager contract /// @param _name The name of the vault constructor( address _asset, - address _auction, address _troveManager, string memory _name ) BaseHooks(_asset, _name) { - // Set immutable addresses - AUCTION = IAuction(_auction); + // Set Trove Manager contract TROVE_MANAGER = ITroveManager(_troveManager); // No deposit limit by default @@ -81,18 +74,6 @@ contract Lender is BaseHooks { return _depositLimit <= _currentAssets ? 0 : _depositLimit - _currentAssets; } - /// @inheritdoc BaseStrategy - function availableWithdrawLimit(address /*_owner*/) public view override returns (uint256) { - // If the strategy is shutdown always allow full withdrawals - if (TokenizedStrategy.isShutdown()) return type(uint256).max; - - // Withdrawals are blocked during ongoing liquidation auctions. - // During liquidation, collateral has been seized but not yet sold for borrow tokens. - // The system is temporarily insolvent until the auction completes and proceeds return to this contract. - // However, any idle liquidity already in the contract remains available for withdrawal - return AUCTION.is_ongoing_liquidation_auction() ? asset.balanceOf(address(this)) : type(uint256).max; - } - // ============================================================================================ // Management functions // ============================================================================================ diff --git a/src/lender/LenderFactory.sol b/src/lender/LenderFactory.sol index a489db2..ab292f8 100644 --- a/src/lender/LenderFactory.sol +++ b/src/lender/LenderFactory.sol @@ -23,7 +23,6 @@ contract LenderFactory { /// @notice Deploy a new Lender contract /// @param _asset The address of the borrow token - /// @param _auction The address of the Auction contract /// @param _troveManager The address of the Trove Manager contract /// @param _management The address of the management /// @param _performanceFeeRecipient The address of the performance fee recipient @@ -31,14 +30,13 @@ contract LenderFactory { /// @return The address of the newly deployed Lender contract function deploy( address _asset, - address _auction, address _troveManager, address _management, address _performanceFeeRecipient, string calldata _name ) external returns (address) { // Deploy the Lender contract - ILender _lender = ILender(address(new Lender(_asset, _auction, _troveManager, _name))); + ILender _lender = ILender(address(new Lender(_asset, _troveManager, _name))); // Set initial parameters _lender.setKeeper(KEEPER); diff --git a/src/lender/interfaces/IAuction.sol b/src/lender/interfaces/IAuction.sol deleted file mode 100644 index e93f4dd..0000000 --- a/src/lender/interfaces/IAuction.sol +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity 0.8.23; - -interface IAuction { - - function is_ongoing_liquidation_auction() external view returns (bool); - -} diff --git a/src/lender/interfaces/ILender.sol b/src/lender/interfaces/ILender.sol index 47327ff..01d8633 100644 --- a/src/lender/interfaces/ILender.sol +++ b/src/lender/interfaces/ILender.sol @@ -9,7 +9,6 @@ interface ILender is IStrategy { // Constants // ============================================================================================ - function AUCTION() external view returns (address); function TROVE_MANAGER() external view returns (address); // ============================================================================================ diff --git a/src/oracles/yvcrvusd2_to_usdc_oracle.vy b/src/oracles/yvcrvusd2_to_usdc_oracle.vy index 7921fd6..d3ce90a 100644 --- a/src/oracles/yvcrvusd2_to_usdc_oracle.vy +++ b/src/oracles/yvcrvusd2_to_usdc_oracle.vy @@ -98,10 +98,10 @@ def get_price(scaled: bool = True) -> uint256: assert usdc_usd_price > 0, "wtf" # Fetch yvcrvUSD-2 price per share - pps: uint256 = staticcall IYearnVault(_COLLATERAL_TOKEN.address).pricePerShare() # yvcrvUSD-2 in USD WAD + pps: uint256 = staticcall IYearnVault(_COLLATERAL_TOKEN.address).pricePerShare() # Calculate yvcrvUSD-2/USDC price - price: uint256 = convert(crvusd_usd_price, uint256) * _WAD // convert(usdc_usd_price, uint256) * pps // _WAD # yvcrvUSD-2 in USDC WAD + price: uint256 = convert(crvusd_usd_price, uint256) * _WAD // convert(usdc_usd_price, uint256) * pps // _WAD # Scale price to the required format if needed and return return price * _ORACLE_SCALE_FACTOR // _WAD if scaled else price \ No newline at end of file diff --git a/src/oracles/yvweth2_to_usdc_oracle.vy b/src/oracles/yvweth2_to_usdc_oracle.vy index 7a6f57c..5a9aa96 100644 --- a/src/oracles/yvweth2_to_usdc_oracle.vy +++ b/src/oracles/yvweth2_to_usdc_oracle.vy @@ -98,10 +98,10 @@ def get_price(scaled: bool = True) -> uint256: assert usdc_usd_price > 0, "wtf" # Fetch yvWETH-2 price per share - pps: uint256 = staticcall IYearnVault(_COLLATERAL_TOKEN.address).pricePerShare() # yvWETH-2 in WETH WAD + pps: uint256 = staticcall IYearnVault(_COLLATERAL_TOKEN.address).pricePerShare() # Calculate yvWETH-2/USDC price - price: uint256 = convert(eth_usd_price, uint256) * _WAD // convert(usdc_usd_price, uint256) * pps // _WAD # yvWETH-2 in USDC WAD + price: uint256 = convert(eth_usd_price, uint256) * _WAD // convert(usdc_usd_price, uint256) * pps // _WAD # Scale price to the required format if needed and return return price * _ORACLE_SCALE_FACTOR // _WAD if scaled else price \ No newline at end of file diff --git a/src/sorted_troves.vy b/src/sorted_troves.vy index 3414251..6447902 100644 --- a/src/sorted_troves.vy +++ b/src/sorted_troves.vy @@ -36,7 +36,7 @@ struct Trove: _ROOT_TROVE_ID: constant(uint256) = 0 _BAD_HINT: constant(uint256) = 0 -_MAX_TROVES: constant(uint256) = 10000 +_MAX_TROVES: constant(uint256) = 10_000 # ============================================================================================ diff --git a/src/trove_manager.vy b/src/trove_manager.vy index 8466fe0..be54161 100644 --- a/src/trove_manager.vy +++ b/src/trove_manager.vy @@ -14,6 +14,7 @@ from ethereum.ercs import IERC20Detailed from snekmate.utils import math +from interfaces import ITaker from interfaces import IDutchDesk from interfaces import IPriceOracle from interfaces import ISortedTroves @@ -87,6 +88,7 @@ event LiquidateTrove: liquidator: indexed(address) collateral_amount: uint256 debt_amount: uint256 + is_full_liquidation: bool event RedeemTrove: trove_id: indexed(uint256) @@ -137,7 +139,11 @@ struct InitializeParams: borrow_token: address collateral_token: address minimum_debt: uint256 + safe_collateral_ratio: uint256 minimum_collateral_ratio: uint256 + max_penalty_collateral_ratio: uint256 + min_liquidation_fee: uint256 + max_liquidation_fee: uint256 upfront_interest_period: uint256 interest_rate_adj_cooldown: uint256 @@ -147,12 +153,12 @@ struct InitializeParams: # ============================================================================================ +_MAX_CALLBACK_DATA_SIZE: constant(uint256) = 10**5 _PRICE_ORACLE_PRECISION: constant(uint256) = 10 ** 36 +_LIQUIDATION_FEE_PRECISION: constant(uint256) = 10_000 _WAD: constant(uint256) = 10 ** 18 -_MAX_LIQUIDATIONS: constant(uint256) = 20 -_MAX_REDEMPTIONS: constant(uint256) = 1000 +_MAX_REDEMPTIONS: constant(uint256) = 1_000 _ONE_YEAR: constant(uint256) = 365 * 60 * 60 * 24 -_REDEMPTION_AUCTION: constant(bool) = False # ============================================================================================ @@ -174,7 +180,11 @@ collateral_token: public(IERC20) one_pct: public(uint256) borrow_token_precision: public(uint256) min_debt: public(uint256) +safe_collateral_ratio: public(uint256) minimum_collateral_ratio: public(uint256) +max_penalty_collateral_ratio: public(uint256) +min_liquidation_fee: public(uint256) +max_liquidation_fee: public(uint256) upfront_interest_period: public(uint256) interest_rate_adj_cooldown: public(uint256) min_annual_interest_rate: public(uint256) @@ -216,14 +226,19 @@ def initialize(params: InitializeParams): # Get borrow token precision borrow_token_precision: uint256 = 10 ** convert(staticcall IERC20Detailed(params.borrow_token).decimals(), uint256) - # Define 1% using borrow token precision + # Define 1% and 0.01% using borrow token precision one_pct: uint256 = borrow_token_precision // 100 + one_hundredth_pct: uint256 = one_pct // 100 # Set market parameters self.one_pct = one_pct self.borrow_token_precision = borrow_token_precision self.min_debt = params.minimum_debt * borrow_token_precision + self.safe_collateral_ratio = params.safe_collateral_ratio * one_pct self.minimum_collateral_ratio = params.minimum_collateral_ratio * one_pct + self.max_penalty_collateral_ratio = params.max_penalty_collateral_ratio * one_pct + self.min_liquidation_fee = params.min_liquidation_fee * one_hundredth_pct + self.max_liquidation_fee = params.max_liquidation_fee * one_hundredth_pct self.upfront_interest_period = params.upfront_interest_period self.interest_rate_adj_cooldown = params.interest_rate_adj_cooldown self.min_annual_interest_rate = one_pct // 2 # 0.5% @@ -943,81 +958,43 @@ def close_zombie_trove(trove_id: uint256): @external -def liquidate_troves(trove_ids: uint256[_MAX_LIQUIDATIONS]): +def liquidate_trove( + trove_id: uint256, + max_debt_to_repay: uint256 = max_value(uint256), + receiver: address = msg.sender, + data: Bytes[_MAX_CALLBACK_DATA_SIZE] = empty(Bytes[_MAX_CALLBACK_DATA_SIZE]) +) -> uint256: """ - @notice Liquidate a list of unhealthy Troves - @dev Uses the Dutch Desk contract to auction off the collateral tokens - @param trove_ids List of unique identifiers of the unhealthy Troves + @notice Liquidate a single unhealthy Trove (fully or partially) + @dev The liquidator repays debt and receives the equivalent collateral plus a dynamic fee + that scales with the Trove's collateral ratio. If remaining debt would fall below + `min_debt`, the entire debt is repaid. Full liquidations close the Trove and + transfer any excess collateral to the owner. Partial liquidations require the Trove + to end up above the minimum collateral ratio. Collateral is sent to the `receiver` + first, then, if `data` is non-empty, a `takeCallback` is invoked on the `receiver`, + before debt is pulled + @param trove_id Unique identifier of the unhealthy Trove + @param max_debt_to_repay Upper bound on debt to repay. May be exceeded when remaining + debt would fall below `min_debt`, forcing full liquidation. Also capped by the + `safe_collateral_ratio` target. Defaults to max uint256 + @param receiver The address that will receive the collateral tokens. Defaults to msg.sender + @param data The data to pass to the `receiver` callback. Defaults to empty + @return The amount of liquidated collateral tokens """ - # Make sure that first trove id is non-zero - assert trove_ids[0] != 0, "!trove_ids" - - # Cache the current zombie trove id to avoid multiple SLOADs inside `_liquidate_single_trove` - current_zombie_trove_id: uint256 = self.zombie_trove_id + # Make sure the trove ID is non-zero + assert trove_id != 0, "!trove_id" # Get the collateral price collateral_price: uint256 = staticcall self.price_oracle.get_price() - # Initialize variables to track total changes - total_collateral_to_decrease: uint256 = 0 - total_debt_to_decrease: uint256 = 0 - total_weighted_debt_to_decrease: uint256 = 0 - - # Initialize variables to capture individual trove liquidation results - # Initializing outside of the loop to save gas on memory expansion - collateral_to_decrease: uint256 = 0 - debt_to_decrease: uint256 = 0 - weighted_debt_decrease: uint256 = 0 - - # Iterate over the Troves and liquidate them one by one - for trove_id: uint256 in trove_ids: - if trove_id == 0: - break - - # Liquidate the Trove and get the changes - ( - collateral_to_decrease, - debt_to_decrease, - weighted_debt_decrease - ) = self._liquidate_single_trove(trove_id, current_zombie_trove_id, collateral_price) - - # Accumulate the total changes - total_collateral_to_decrease += collateral_to_decrease - total_debt_to_decrease += debt_to_decrease - total_weighted_debt_to_decrease += weighted_debt_decrease - - # Update the contract's recorded collateral balance - self.collateral_balance -= total_collateral_to_decrease - - # Accrue interest on the total debt and update accounting - self._accrue_interest_and_account_for_trove_change( - 0, # debt_increase - total_debt_to_decrease, # debt_decrease - 0, # weighted_debt_increase - total_weighted_debt_to_decrease, # weighted_debt_decrease - ) - - # Kick the auction. Proceeds will be sent to the Lender contract - extcall self.dutch_desk.kick(total_collateral_to_decrease) # pulls collateral tokens - - -@internal -def _liquidate_single_trove(trove_id: uint256, current_zombie_trove_id: uint256, collateral_price: uint256) -> (uint256, uint256, uint256): - """ - @notice Internal function to liquidate a single unhealthy Trove - @dev Does not update global accounting or handle token transfers - @param trove_id Unique identifier of the Trove - @param current_zombie_trove_id Current zombie trove id - @param collateral_price Current collateral price - @return collateral_to_decrease Amount of collateral to subtract from the total collateral - @return debt_to_decrease Amount of debt to subtract from the total debt - @return weighted_debt_decrease Amount of weighted debt to subtract from the total weighted debt - """ - # Cache Trove info + # Cache the Trove info trove: Trove = self.troves[trove_id] + # Check if the Trove is active and cache the result + is_active: bool = trove.status == Status.ACTIVE + # Make sure the Trove is active or zombie - assert trove.status == Status.ACTIVE or trove.status == Status.ZOMBIE, "!active or zombie" + assert is_active or trove.status == Status.ZOMBIE, "!active or zombie" # Get the Trove's debt after accruing interest trove_debt_after_interest: uint256 = self._get_trove_debt_after_interest(trove) @@ -1027,41 +1004,175 @@ def _liquidate_single_trove(trove_id: uint256, current_zombie_trove_id: uint256, trove.collateral, trove_debt_after_interest, collateral_price ) + # Cache the minimum collateral ratio + minimum_collateral_ratio: uint256 = self.minimum_collateral_ratio + # Make sure the collateral ratio is below the minimum collateral ratio - assert collateral_ratio < self.minimum_collateral_ratio, "!collateral_ratio" + assert collateral_ratio < minimum_collateral_ratio, "!collateral_ratio" + + # Determine the liquidation fee percentage based on the collateral ratio: + # - At or above minimum collateral ratio --> minimum fee + # - At or below maximum penalty collateral ratio --> maximum fee + # - Between the two --> linear interpolation + liquidation_fee_pct: uint256 = 0 + if collateral_ratio >= minimum_collateral_ratio: + liquidation_fee_pct = self.min_liquidation_fee + elif collateral_ratio <= self.max_penalty_collateral_ratio: + liquidation_fee_pct = self.max_liquidation_fee + else: + min_liquidation_fee: uint256 = self.min_liquidation_fee + collateral_ratio_range: uint256 = minimum_collateral_ratio - self.max_penalty_collateral_ratio + collateral_ratio_drop: uint256 = minimum_collateral_ratio - collateral_ratio + fee_range: uint256 = self.max_liquidation_fee - min_liquidation_fee + liquidation_fee_pct = min_liquidation_fee + (fee_range * collateral_ratio_drop // collateral_ratio_range) + + # Cache the borrow token precision + borrow_token_precision: uint256 = self.borrow_token_precision + + # Convert the Trove's collateral to its equivalent in borrow tokens + trove_collateral_in_borrow: uint256 = trove.collateral * collateral_price // _PRICE_ORACLE_PRECISION + + # Cache the safe collateral ratio + safe_collateral_ratio: uint256 = self.safe_collateral_ratio + + # Calculate the maximum debt to repay to bring the Trove to `safe_collateral_ratio`. + # + # Derived from setting new_cr = safe_cr after reducing debt by `d` and collateral by `d * (1 + fee)`: + # safe_cr = (cv - d * (1 + fee)) / (D - d) + # Solving for `d`: + # d = (safe_cr * D - cv) / (safe_cr - 1 - fee) + # + # Where cv = trove_collateral_in_borrow, D = trove_debt_after_interest, + # and all values are scaled by borrow_token_precision + debt_to_repay_for_safe_collateral_ratio: uint256 = ( + safe_collateral_ratio * trove_debt_after_interest - trove_collateral_in_borrow * borrow_token_precision + ) // (safe_collateral_ratio - borrow_token_precision - liquidation_fee_pct) + + # Determine the debt amount to repay: + # - Cap at `max_debt_to_repay` + # - Cap at the amount that brings the Trove to `safe_collateral_ratio` + # - If remaining debt would fall below `min_debt`, repay the entire debt + debt_to_repay: uint256 = min(min(trove_debt_after_interest, max_debt_to_repay), debt_to_repay_for_safe_collateral_ratio) + if trove_debt_after_interest - debt_to_repay < self.min_debt: + debt_to_repay = trove_debt_after_interest + + # Convert the debt to repay to its equivalent in collateral tokens + debt_to_repay_in_collateral: uint256 = debt_to_repay * _PRICE_ORACLE_PRECISION // collateral_price + + # Apply the liquidation fee + collateral_with_fee: uint256 = debt_to_repay_in_collateral * (borrow_token_precision + liquidation_fee_pct) // borrow_token_precision + + # Determine how much collateral to liquidate, cap at the Trove's collateral + collateral_to_liquidate: uint256 = min(collateral_with_fee, trove.collateral) + + # Cache the Trove owner before the Trove is potentially emptied + trove_owner: address = trove.owner + + # Check if this is a full liquidation and cache the result + is_full_liquidation: bool = debt_to_repay == trove_debt_after_interest + + # Full liquidation: close the Trove and transfer any remaining collateral to the owner. + # Partial liquidation: reduce the Trove's debt and collateral and keep it open. + # After a partial liquidation, the Trove must end up above the minimum collateral ratio + if is_full_liquidation: + # Cache the Trove's collateral and weighted debt for global accounting + trove_collateral: uint256 = trove.collateral + trove_weighted_debt: uint256 = trove.debt * trove.annual_interest_rate + + # Delete all Trove info and mark it as liquidated + trove = empty(Trove) + trove.status = Status.LIQUIDATED + + # Save changes to storage + self.troves[trove_id] = trove + + # If Trove is the current zombie trove, reset the `zombie_trove_id` variable + if self.zombie_trove_id == trove_id: + self.zombie_trove_id = 0 + + # Update the contract's recorded collateral balance + self.collateral_balance -= trove_collateral - # Cache the Trove's old info for global accounting - old_trove: Trove = trove + # Accrue interest on the total debt and update accounting + self._accrue_interest_and_account_for_trove_change( + 0, # debt_increase + debt_to_repay, # debt_decrease + 0, # weighted_debt_increase + trove_weighted_debt, # weighted_debt_decrease + ) - # Delete all Trove info and mark it as liquidated - trove = empty(Trove) - trove.status = Status.LIQUIDATED + # If Trove was active, remove from sorted list + if is_active: + extcall self.sorted_troves.remove(trove_id) - # Save changes to storage - self.troves[trove_id] = trove + # Calculate the remaining collateral to transfer to the Trove owner (if any) + remaining_collateral: uint256 = trove_collateral - collateral_to_liquidate - # If Trove is the current zombie trove, reset the `zombie_trove_id` variable - if current_zombie_trove_id == trove_id: - self.zombie_trove_id = 0 + # If needed, transfer the remaining collateral tokens to Trove owner + if remaining_collateral > 0: + assert extcall self.collateral_token.transfer(trove_owner, remaining_collateral, default_return_value=True) + else: + # Calculate the new debt amount + new_debt: uint256 = trove_debt_after_interest - debt_to_repay + + # Cache the Trove's old debt for global accounting + old_debt: uint256 = trove.debt + + # Calculate the new collateral amount and collateral ratio + new_collateral: uint256 = trove.collateral - collateral_to_liquidate + new_collateral_ratio: uint256 = self._calculate_collateral_ratio(new_collateral, new_debt, collateral_price) + + # Make sure the new collateral ratio is above the minimum collateral ratio + assert new_collateral_ratio >= minimum_collateral_ratio, "!minimum_collateral_ratio" + + # Update the Trove's info + trove.debt = new_debt + trove.collateral = new_collateral + trove.last_debt_update_time = convert(block.timestamp, uint64) - # Remove from sorted list if it was active - if old_trove.status == Status.ACTIVE: - extcall self.sorted_troves.remove(trove_id) + # Save changes to storage + self.troves[trove_id] = trove + + # Update the contract's recorded collateral balance + self.collateral_balance -= collateral_to_liquidate + + # Accrue interest on the total debt and update accounting + self._accrue_interest_and_account_for_trove_change( + 0, # debt_increase + debt_to_repay, # debt_decrease + new_debt * trove.annual_interest_rate, # weighted_debt_increase + old_debt * trove.annual_interest_rate, # weighted_debt_decrease + ) + + # Transfer the collateral tokens to the `receiver` + assert extcall self.collateral_token.transfer(receiver, collateral_to_liquidate, default_return_value=True) + + # If the caller provided data, perform the callback. + # No reentrancy concern: all state updates are complete before the external call (CEI) + if len(data) != 0: + extcall ITaker(receiver).takeCallback( + trove_id, + msg.sender, + collateral_to_liquidate, # amount_taken + debt_to_repay, # needed_amount + data, + ) + + # Pull the borrow tokens from caller and transfer them to the Lender contract + assert extcall self.borrow_token.transferFrom(msg.sender, self.lender, debt_to_repay, default_return_value=True) # Emit event log LiquidateTrove( trove_id=trove_id, - trove_owner=old_trove.owner, + trove_owner=trove_owner, liquidator=msg.sender, - collateral_amount=old_trove.collateral, - debt_amount=trove_debt_after_interest, + collateral_amount=collateral_to_liquidate, + debt_amount=debt_to_repay, + is_full_liquidation=is_full_liquidation, ) - return ( - old_trove.collateral, # collateral_to_decrease - trove_debt_after_interest, # debt_to_decrease - old_trove.debt * old_trove.annual_interest_rate # weighted_debt_decrease - ) + # Return the amount of liquidated collateral tokens + return collateral_to_liquidate # ============================================================================================ @@ -1238,7 +1349,7 @@ def _redeem( # Kick the auction # Proceeds up to `total_debt_decrease` will be sent to the `receiver`, any surplus will be sent to the Lender contract - extcall self.dutch_desk.kick(total_collateral_decrease, total_debt_decrease, receiver, _REDEMPTION_AUCTION) # pulls collateral tokens + extcall self.dutch_desk.kick(total_collateral_decrease, total_debt_decrease, receiver) # pulls collateral tokens # Emit event log Redeem( @@ -1289,6 +1400,7 @@ def _calculate_accrued_interest(weighted_debt: uint256, period: uint256) -> uint return weighted_debt * period // _ONE_YEAR // self.borrow_token_precision + @internal @view def _get_upfront_fee( diff --git a/test/Auction.t.sol b/test/Auction.t.sol index a5ae09c..66434e4 100644 --- a/test/Auction.t.sol +++ b/test/Auction.t.sol @@ -28,7 +28,6 @@ contract AuctionTests is Base { assertEq(auction.step_duration(), stepDuration, "E5"); assertEq(auction.step_decay_rate(), stepDecayRate, "E6"); assertEq(auction.auction_length(), auctionLength, "E7"); - assertEq(auction.liquidation_auctions(), 0, "E8"); } function test_initialize_revertsIfAlreadyInitialized() public { @@ -36,11 +35,11 @@ contract AuctionTests is Base { auction.initialize( IAuction.InitializeParams({ papi: address(dutchDesk), - buyToken: address(borrowToken), - sellToken: address(collateralToken), - stepDuration: stepDuration, - stepDecayRate: stepDecayRate, - auctionLength: auctionLength + buy_token: address(borrowToken), + sell_token: address(collateralToken), + step_duration: stepDuration, + step_decay_rate: stepDecayRate, + auction_length: auctionLength }) ); } @@ -62,49 +61,21 @@ contract AuctionTests is Base { // Kick auction as dutchDesk vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, _maximumAmount, _startingPrice, _minimumPrice, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, _maximumAmount, _startingPrice, _minimumPrice, userLender, address(lender)); // Check auction state assertTrue(auction.is_active(_auctionId), "E0"); IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - assertEq(auctionInfo.kickTimestamp, block.timestamp, "E1"); - assertEq(auctionInfo.initialAmount, _kickAmount, "E2"); - assertEq(auctionInfo.currentAmount, _kickAmount, "E3"); - assertEq(auctionInfo.maximumAmount, _maximumAmount, "E4"); - assertEq(auctionInfo.amountReceived, 0, "E5"); - assertEq(auctionInfo.startingPrice, _startingPrice, "E6"); - assertEq(auctionInfo.minimumPrice, _minimumPrice, "E7"); + assertEq(auctionInfo.kick_timestamp, block.timestamp, "E1"); + assertEq(auctionInfo.initial_amount, _kickAmount, "E2"); + assertEq(auctionInfo.current_amount, _kickAmount, "E3"); + assertEq(auctionInfo.maximum_amount, _maximumAmount, "E4"); + assertEq(auctionInfo.amount_received, 0, "E5"); + assertEq(auctionInfo.starting_price, _startingPrice, "E6"); + assertEq(auctionInfo.minimum_price, _minimumPrice, "E7"); assertEq(auctionInfo.receiver, userLender, "E8"); - assertEq(auctionInfo.surplusReceiver, address(lender), "E9"); - assertFalse(auctionInfo.isLiquidation, "E10"); - assertFalse(auction.is_ongoing_liquidation_auction(), "E11"); - assertEq(auction.liquidation_auctions(), 0, "E12"); - assertEq(collateralToken.balanceOf(address(auction)), _kickAmount, "E13"); - } - - function test_kick_liquidation( - uint256 _auctionId, - uint256 _kickAmount, - uint256 _startingPrice, - uint256 _minimumPrice - ) public { - _kickAmount = bound(_kickAmount, minFuzzAmount, maxFuzzAmount); - _startingPrice = bound(_startingPrice, _kickAmount * 1e18, _kickAmount * 100e18); - _minimumPrice = bound(_minimumPrice, 1, _startingPrice / _kickAmount); - - // Airdrop collateral to dutchDesk - airdrop(address(collateralToken), address(dutchDesk), _kickAmount); - - // Kick liquidation auction (maximum_amount=0 for liquidations since all goes to receiver anyway) - vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, address(lender), address(lender), true); - - // Check liquidation state - assertTrue(auction.is_active(_auctionId), "E0"); - assertTrue(auction.auctions(_auctionId).isLiquidation, "E1"); - assertTrue(auction.is_ongoing_liquidation_auction(), "E2"); - assertEq(auction.liquidation_auctions(), 1, "E3"); - assertEq(auction.auctions(_auctionId).receiver, address(lender), "E4"); + assertEq(auctionInfo.surplus_receiver, address(lender), "E9"); + assertEq(collateralToken.balanceOf(address(auction)), _kickAmount, "E10"); } function test_kick_notPapi( @@ -120,7 +91,7 @@ contract AuctionTests is Base { // Should revert when called by non-PAPI vm.prank(userBorrower); vm.expectRevert("!papi"); - auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, userLender, address(lender)); } function test_kick_zeroAmount( @@ -128,7 +99,7 @@ contract AuctionTests is Base { ) public { vm.prank(address(dutchDesk)); vm.expectRevert("!kick_amount"); - auction.kick(_auctionId, 0, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, 0, 0, 1e18, 1e17, userLender, address(lender)); } function test_kick_zeroStartingPrice( @@ -139,7 +110,7 @@ contract AuctionTests is Base { vm.prank(address(dutchDesk)); vm.expectRevert("!starting_price"); - auction.kick(_auctionId, _kickAmount, 0, 0, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 0, 1e17, userLender, address(lender)); } function test_kick_zeroMinimumPrice( @@ -152,7 +123,7 @@ contract AuctionTests is Base { vm.prank(address(dutchDesk)); vm.expectRevert("!minimum_price"); - auction.kick(_auctionId, _kickAmount, 0, _startingPrice, 0, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _startingPrice, 0, userLender, address(lender)); } function test_kick_zeroReceiver( @@ -167,7 +138,7 @@ contract AuctionTests is Base { vm.prank(address(dutchDesk)); vm.expectRevert("!receiver"); - auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, address(0), address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, address(0), address(lender)); } function test_kick_zeroSurplusReceiver( @@ -182,7 +153,7 @@ contract AuctionTests is Base { vm.prank(address(dutchDesk)); vm.expectRevert("!surplus_receiver"); - auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, userLender, address(0), false); + auction.kick(_auctionId, _kickAmount, 0, _startingPrice, _minimumPrice, userLender, address(0)); } function test_kick_auctionAlreadyActive( @@ -196,12 +167,12 @@ contract AuctionTests is Base { // First kick succeeds vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Second kick on same ID should revert vm.prank(address(dutchDesk)); vm.expectRevert("active"); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); } function test_reKick( @@ -213,7 +184,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Skip past auction duration so it becomes inactive skip(auction.auction_length() + 1); @@ -229,10 +200,10 @@ contract AuctionTests is Base { // Verify auction is active again with updated prices assertTrue(auction.is_active(_auctionId), "E2"); IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - assertEq(auctionInfo.currentAmount, _kickAmount, "E3"); - assertEq(auctionInfo.kickTimestamp, block.timestamp, "E4"); - assertEq(auctionInfo.startingPrice, _kickAmount * 1e18, "E5"); - assertEq(auctionInfo.minimumPrice, 1e17, "E6"); + assertEq(auctionInfo.current_amount, _kickAmount, "E3"); + assertEq(auctionInfo.kick_timestamp, block.timestamp, "E4"); + assertEq(auctionInfo.starting_price, _kickAmount * 1e18, "E5"); + assertEq(auctionInfo.minimum_price, 1e17, "E6"); } function test_reKick_notPapi( @@ -252,7 +223,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Try to re-kick while still active - should revert vm.prank(address(dutchDesk)); @@ -278,7 +249,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip past auction duration so it becomes inactive skip(auction.auction_length() + 1); @@ -298,7 +269,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip past auction duration so it becomes inactive skip(auction.auction_length() + 1); @@ -318,7 +289,7 @@ contract AuctionTests is Base { // Airdrop and kick with high maximum_amount (so no surplus) airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, type(uint256).max, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, type(uint256).max, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Skip some time to let price decay skip(auction.step_duration() * 10); @@ -336,13 +307,13 @@ contract AuctionTests is Base { // Verify auction is complete assertFalse(auction.is_active(_auctionId), "E0"); IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - assertEq(auctionInfo.currentAmount, 0, "E1"); - assertEq(auctionInfo.kickTimestamp, 0, "E2"); + assertEq(auctionInfo.current_amount, 0, "E1"); + assertEq(auctionInfo.kick_timestamp, 0, "E2"); // Verify token transfers - all goes to receiver since neededAmount <= maximum_amount assertEq(collateralToken.balanceOf(liquidator), _kickAmount, "E3"); assertEq(borrowToken.balanceOf(userLender), _neededAmount, "E4"); - assertEq(auctionInfo.amountReceived, _neededAmount, "E5"); + assertEq(auctionInfo.amount_received, _neededAmount, "E5"); } function test_take_surplusToLender( @@ -361,7 +332,7 @@ contract AuctionTests is Base { // Airdrop and kick with low maximum_amount airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, _maximumAmount, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, _maximumAmount, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Get actual needed amount uint256 _neededAmount = auction.get_needed_amount(_auctionId, type(uint256).max, block.timestamp); @@ -378,7 +349,7 @@ contract AuctionTests is Base { uint256 _surplus = _neededAmount > _maximumAmount ? _neededAmount - _maximumAmount : 0; assertEq(borrowToken.balanceOf(address(lender)) - _surplusReceiverBalanceBefore, _surplus, "E0"); assertEq(borrowToken.balanceOf(userLender), _maximumAmount, "E1"); - assertEq(auction.auctions(_auctionId).amountReceived, _maximumAmount, "E2"); + assertEq(auction.auctions(_auctionId).amount_received, _maximumAmount, "E2"); } function test_take_liquidationAllToReceiver( @@ -390,7 +361,7 @@ contract AuctionTests is Base { // Airdrop and kick as liquidation (receiver=lender for liquidations) airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, address(lender), address(lender), true); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, address(lender), address(lender)); // Skip some time to let price decay skip(auction.step_duration() * 10); @@ -419,7 +390,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, type(uint256).max, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, type(uint256).max, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Skip some time skip(auction.step_duration() * 10); @@ -437,9 +408,9 @@ contract AuctionTests is Base { // Verify auction is still active with remaining amount assertTrue(auction.is_active(_auctionId), "E0"); IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - assertEq(auctionInfo.currentAmount, _kickAmount - _takeAmount, "E1"); + assertEq(auctionInfo.current_amount, _kickAmount - _takeAmount, "E1"); assertEq(collateralToken.balanceOf(liquidator), _takeAmount, "E2"); - assertEq(auctionInfo.amountReceived, _neededAmount, "E3"); + assertEq(auctionInfo.amount_received, _neededAmount, "E3"); } function test_take_multipleTakes_receiverCoveredFirst( @@ -452,7 +423,7 @@ contract AuctionTests is Base { // Kick auction airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(0, _kickAmount, _maximumAmount, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(0, _kickAmount, _maximumAmount, _kickAmount * 1e18, 1e17, userLender, address(lender)); uint256 _surplusReceiverBefore = borrowToken.balanceOf(address(lender)); @@ -465,11 +436,11 @@ contract AuctionTests is Base { vm.stopPrank(); // Second take: remaining - uint256 _needed2 = auction.get_needed_amount(0, auction.auctions(0).currentAmount, block.timestamp); + uint256 _needed2 = auction.get_needed_amount(0, auction.auctions(0).current_amount, block.timestamp); airdrop(address(borrowToken), liquidator, _needed2); vm.startPrank(liquidator); borrowToken.approve(address(auction), _needed2); - auction.take(0, auction.auctions(0).currentAmount, liquidator, ""); + auction.take(0, auction.auctions(0).current_amount, liquidator, ""); vm.stopPrank(); // Receiver capped at maximum_amount, surplus to surplus_receiver @@ -495,7 +466,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Try to take 0 amount vm.prank(liquidator); @@ -503,36 +474,6 @@ contract AuctionTests is Base { auction.take(_auctionId, 0, liquidator, ""); } - function test_take_liquidationDecreasesCounter( - uint256 _auctionId, - uint256 _kickAmount - ) public { - _kickAmount = bound(_kickAmount, minFuzzAmount, maxFuzzAmount); - - // Airdrop and kick as liquidation - airdrop(address(collateralToken), address(dutchDesk), _kickAmount); - vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, address(lender), address(lender), true); - - // Verify liquidation counter incremented - assertEq(auction.liquidation_auctions(), 1, "E0"); - assertTrue(auction.is_ongoing_liquidation_auction(), "E1"); - - // Skip time and take - skip(auction.step_duration() * 10); - uint256 _neededAmount = auction.get_needed_amount(_auctionId, type(uint256).max, block.timestamp); - airdrop(address(borrowToken), liquidator, _neededAmount); - - vm.startPrank(liquidator); - borrowToken.approve(address(auction), _neededAmount); - auction.take(_auctionId, type(uint256).max, liquidator, ""); - vm.stopPrank(); - - // Verify liquidation counter decremented - assertEq(auction.liquidation_auctions(), 0, "E2"); - assertFalse(auction.is_ongoing_liquidation_auction(), "E3"); - } - function test_priceDecay( uint256 _auctionId, uint256 _kickAmount @@ -542,7 +483,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Get initial price uint256 _initialPrice = auction.get_price(_auctionId, block.timestamp); @@ -574,7 +515,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Verify auction is active assertTrue(auction.is_active(_auctionId), "E0"); @@ -601,7 +542,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // After kick, available amount should equal kick amount assertEq(auction.get_available_amount(_auctionId), _kickAmount, "E1"); @@ -623,7 +564,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // While active, kickable amount should be 0 assertEq(auction.get_kickable_amount(_auctionId), 0, "E1"); @@ -642,7 +583,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, _kickAmount * 1e18, 1e17, userLender, address(lender)); // Get needed amount for full take uint256 _neededAmount = auction.get_needed_amount(_auctionId, _kickAmount, block.timestamp); @@ -670,7 +611,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip some time to let price decay skip(auction.step_duration() * 10); @@ -704,7 +645,7 @@ contract AuctionTests is Base { // Kick auction vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip some time skip(auction.step_duration() * 10); @@ -735,7 +676,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip some time skip(auction.step_duration() * 10); @@ -766,7 +707,7 @@ contract AuctionTests is Base { // Airdrop and kick airdrop(address(collateralToken), address(dutchDesk), _kickAmount); vm.prank(address(dutchDesk)); - auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender), false); + auction.kick(_auctionId, _kickAmount, 0, 1e18, 1e17, userLender, address(lender)); // Skip some time skip(auction.step_duration() * 10); @@ -844,7 +785,7 @@ contract ReentrantTaker { if (attackType == AttackType.Take) { auction.take(auctionId, takeAmount, address(this), ""); } else if (attackType == AttackType.Kick) { - auction.kick(auctionId, 1e18, 0, 1e18, 1e17, address(this), address(this), false); + auction.kick(auctionId, 1e18, 0, 1e18, 1e17, address(this), address(this)); } else if (attackType == AttackType.ReKick) { auction.re_kick(auctionId, 2e18, 2e17); } else if (attackType == AttackType.ViewFunctions) { diff --git a/test/AuctionPriceSimulation.t.sol b/test/AuctionPriceSimulation.t.sol index 95b57a7..f805df9 100644 --- a/test/AuctionPriceSimulation.t.sol +++ b/test/AuctionPriceSimulation.t.sol @@ -31,7 +31,7 @@ contract AuctionPriceSimulationTests is Base { uint256 marketPrice = 1000e18; // Starting price with buffer (e.g., 15% above market) - uint256 auctionStartingPrice = (marketPrice * redemptionStartingPriceBufferPercentage) / WAD; + uint256 auctionStartingPrice = (marketPrice * startingPriceBufferPercentage) / WAD; uint256 price = auctionStartingPrice; uint256 multiplier = 10000 - stepDecayRate; @@ -40,7 +40,7 @@ contract AuctionPriceSimulationTests is Base { console2.log(""); console2.log("=== Time to Reach Market Price ==="); console2.log("Market Price: %s", marketPrice / 1e18); - console2.log("Starting Price Buffer: %s%%", redemptionStartingPriceBufferPercentage / 1e16); + console2.log("Starting Price Buffer: %s%%", startingPriceBufferPercentage / 1e16); console2.log("Auction Starting Price: %s", auctionStartingPrice / 1e18); console2.log("Step Duration: %s seconds", stepDuration); console2.log("Step Decay Rate: %s basis points", stepDecayRate); diff --git a/test/Base.sol b/test/Base.sol index cdecbfe..2e6f98d 100644 --- a/test/Base.sol +++ b/test/Base.sol @@ -12,6 +12,8 @@ import {IPriceOracle} from "./interfaces/IPriceOracle.sol"; import {ISortedTroves} from "./interfaces/ISortedTroves.sol"; import {ITroveManager} from "./interfaces/ITroveManager.sol"; +import {LiquidatorMock} from "./mocks/LiquidatorMock.sol"; + import "../script/Deploy.s.sol"; import "forge-std/Test.sol"; @@ -25,6 +27,7 @@ abstract contract Base is Deploy, Test { IDutchDesk public dutchDesk; ISortedTroves public sortedTroves; ITroveManager public troveManager; + LiquidatorMock public liquidatorMock; // Roles address public userLender = address(420); @@ -37,14 +40,16 @@ abstract contract Base is Deploy, Test { // Market parameters uint256 public minimumDebt = 500; // 500 tokens + uint256 public safeCollateralRatio = 115; // 115% uint256 public minimumCollateralRatio = 110; // 110% + uint256 public maxPenaltyCollateralRatio = 105; // 105% + uint256 public minLiquidationFee = 50; // 0.5% + uint256 public maxLiquidationFee = 500; // 5% uint256 public upfrontInterestPeriod = 7 days; // 7 days uint256 public interestRateAdjCooldown = 7 days; // 7 days - uint256 public redemptionMinimumPriceBufferPercentage = 1e18 - 5e16; // 95% - uint256 public redemptionStartingPriceBufferPercentage = 1e18 + 1e16; // 101% - uint256 public redemptionReKickStartingPriceBufferPercentage = 1e18 + 20e16; // 120% - uint256 public liquidationMinimumPriceBufferPercentage = 1e18 - 10e16; // 90% - uint256 public liquidationStartingPriceBufferPercentage = 1e18 - 1e16; // 99% + uint256 public minimumPriceBufferPercentage = 1e18 - 5e16; // 95% + uint256 public startingPriceBufferPercentage = 1e18 + 1e16; // 101% + uint256 public reKickStartingPriceBufferPercentage = 1e18 + 10e16; // 110% uint256 public stepDuration = 20; // 20 seconds uint256 public stepDecayRate = 20; // 0.2% uint256 public auctionLength = 1 days; // 1 day @@ -58,7 +63,6 @@ abstract contract Base is Deploy, Test { uint256 public DEFAULT_ANNUAL_INTEREST_RATE; uint256 public DEFAULT_TARGET_COLLATERAL_RATIO; - uint256 public constant MAX_LIQUIDATIONS = 20; uint256 public constant ORACLE_PRICE_SCALE = 1e36; uint256 public constant WAD = 1e18; @@ -79,23 +83,25 @@ abstract contract Base is Deploy, Test { // Deploy market (address _troveManager, address _sortedTroves, address _dutchDesk, address _auction, address _lender) = catFactory.deploy( ICatFactory.DeployParams({ - borrowToken: address(borrowToken), - collateralToken: address(collateralToken), - priceOracle: address(priceOracle), + borrow_token: address(borrowToken), + collateral_token: address(collateralToken), + price_oracle: address(priceOracle), management: management, - performanceFeeRecipient: performanceFeeRecipient, - minimumDebt: minimumDebt, - minimumCollateralRatio: minimumCollateralRatio, - upfrontInterestPeriod: upfrontInterestPeriod, - interestRateAdjCooldown: interestRateAdjCooldown, - redemptionMinimumPriceBufferPercentage: redemptionMinimumPriceBufferPercentage, - redemptionStartingPriceBufferPercentage: redemptionStartingPriceBufferPercentage, - redemptionReKickStartingPriceBufferPercentage: redemptionReKickStartingPriceBufferPercentage, - liquidationMinimumPriceBufferPercentage: liquidationMinimumPriceBufferPercentage, - liquidationStartingPriceBufferPercentage: liquidationStartingPriceBufferPercentage, - stepDuration: stepDuration, - stepDecayRate: stepDecayRate, - auctionLength: auctionLength, + performance_fee_recipient: performanceFeeRecipient, + minimum_debt: minimumDebt, + safe_collateral_ratio: safeCollateralRatio, + minimum_collateral_ratio: minimumCollateralRatio, + max_penalty_collateral_ratio: maxPenaltyCollateralRatio, + min_liquidation_fee: minLiquidationFee, + max_liquidation_fee: maxLiquidationFee, + upfront_interest_period: upfrontInterestPeriod, + interest_rate_adj_cooldown: interestRateAdjCooldown, + minimum_price_buffer_percentage: minimumPriceBufferPercentage, + starting_price_buffer_percentage: startingPriceBufferPercentage, + re_kick_starting_price_buffer_percentage: reKickStartingPriceBufferPercentage, + step_duration: stepDuration, + step_decay_rate: stepDecayRate, + auction_length: auctionLength, salt: SALT }) ); @@ -107,6 +113,9 @@ abstract contract Base is Deploy, Test { auction = IAuction(_auction); lender = ILender(_lender); + // Deploy liquidator mock + liquidatorMock = new LiquidatorMock(troveManager, borrowToken); + // Label addresses vm.label(address(troveManager), "TroveManager"); vm.label(address(sortedTroves), "SortedTroves"); @@ -158,6 +167,12 @@ abstract contract Base is Deploy, Test { } } + function liquidate( + uint256 _troveId + ) public returns (uint256) { + return liquidatorMock.liquidate(_troveId, type(uint256).max); + } + function takeAuction( uint256 _auctionId ) public returns (uint256) { @@ -211,6 +226,35 @@ abstract contract Base is Deploy, Test { depositIntoLender(_user, _amount); } + function calculateCollateralToDecrease( + uint256 _collateralRatio, + uint256 _debtToDecrease, + uint256 _collateralPrice, + uint256 _troveCollateral + ) public view returns (uint256) { + uint256 _liquidationFeePct; + uint256 _mcr = troveManager.minimum_collateral_ratio(); + uint256 _maxPenaltyCR = troveManager.max_penalty_collateral_ratio(); + + if (_collateralRatio >= _mcr) { + _liquidationFeePct = troveManager.min_liquidation_fee(); + } else if (_collateralRatio <= _maxPenaltyCR) { + _liquidationFeePct = troveManager.max_liquidation_fee(); + } else { + uint256 _minFee = troveManager.min_liquidation_fee(); + uint256 _feeRange = troveManager.max_liquidation_fee() - _minFee; + uint256 _crDrop = _mcr - _collateralRatio; + uint256 _crRange = _mcr - _maxPenaltyCR; + _liquidationFeePct = _minFee + (_feeRange * _crDrop / _crRange); + } + + uint256 _baseCollateral = _debtToDecrease * ORACLE_PRICE_SCALE / _collateralPrice; + uint256 _collateralToDecrease = _baseCollateral * (BORROW_TOKEN_PRECISION + _liquidationFeePct) / BORROW_TOKEN_PRECISION; + + if (_collateralToDecrease > _troveCollateral) return _troveCollateral; + return _collateralToDecrease; + } + function mintAndOpenTrove( address _user, uint256 _collateralAmount, diff --git a/test/Borrow.t.sol b/test/Borrow.t.sol index 2c9bed3..5d199aa 100644 --- a/test/Borrow.t.sol +++ b/test/Borrow.t.sol @@ -401,15 +401,14 @@ contract BorrowTests is Base { // Check starting price is set correctly (with buffer) assertApproxEqAbs( - auction.auctions(0).startingPrice, - _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.redemption_starting_price_buffer_percentage() - / COLLATERAL_TOKEN_PRECISION, + auction.auctions(0).starting_price, + _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.starting_price_buffer_percentage() / COLLATERAL_TOKEN_PRECISION, 3, "E53" ); // Check minimum price is set correctly (with buffer) - assertEq(auction.auctions(0).minimumPrice, priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD, "E54"); + assertEq(auction.auctions(0).minimum_price, priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD, "E54"); // Take the auction takeAuction(0); diff --git a/test/DutchDesk.t.sol b/test/DutchDesk.t.sol index c012d83..9db3139 100644 --- a/test/DutchDesk.t.sol +++ b/test/DutchDesk.t.sol @@ -26,70 +26,30 @@ contract DutchDeskTests is Base { assertEq(dutchDesk.auction(), address(auction), "E3"); assertEq(dutchDesk.collateral_token(), address(collateralToken), "E4"); assertEq(dutchDesk.collateral_token_precision(), COLLATERAL_TOKEN_PRECISION, "E5"); - assertEq(dutchDesk.redemption_minimum_price_buffer_percentage(), redemptionMinimumPriceBufferPercentage, "E6"); - assertEq(dutchDesk.redemption_starting_price_buffer_percentage(), redemptionStartingPriceBufferPercentage, "E7"); - assertEq(dutchDesk.redemption_re_kick_starting_price_buffer_percentage(), redemptionReKickStartingPriceBufferPercentage, "E8"); - assertEq(dutchDesk.liquidation_minimum_price_buffer_percentage(), liquidationMinimumPriceBufferPercentage, "E9"); - assertEq(dutchDesk.liquidation_starting_price_buffer_percentage(), liquidationStartingPriceBufferPercentage, "E10"); - assertEq(dutchDesk.nonce(), 0, "E11"); + assertEq(dutchDesk.minimum_price_buffer_percentage(), minimumPriceBufferPercentage, "E6"); + assertEq(dutchDesk.starting_price_buffer_percentage(), startingPriceBufferPercentage, "E7"); + assertEq(dutchDesk.re_kick_starting_price_buffer_percentage(), reKickStartingPriceBufferPercentage, "E8"); + assertEq(dutchDesk.nonce(), 0, "E9"); } function test_initialize_revertsIfAlreadyInitialized() public { vm.expectRevert("initialized"); dutchDesk.initialize( IDutchDesk.InitializeParams({ - troveManager: address(troveManager), + trove_manager: address(troveManager), lender: address(lender), - priceOracle: address(priceOracle), + price_oracle: address(priceOracle), auction: address(auction), - borrowToken: address(borrowToken), - collateralToken: address(collateralToken), - redemptionMinimumPriceBufferPercentage: redemptionMinimumPriceBufferPercentage, - redemptionStartingPriceBufferPercentage: redemptionStartingPriceBufferPercentage, - redemptionReKickStartingPriceBufferPercentage: redemptionReKickStartingPriceBufferPercentage, - liquidationMinimumPriceBufferPercentage: liquidationMinimumPriceBufferPercentage, - liquidationStartingPriceBufferPercentage: liquidationStartingPriceBufferPercentage + borrow_token: address(borrowToken), + collateral_token: address(collateralToken), + minimum_price_buffer_percentage: minimumPriceBufferPercentage, + starting_price_buffer_percentage: startingPriceBufferPercentage, + re_kick_starting_price_buffer_percentage: reKickStartingPriceBufferPercentage }) ); } - function test_kick_liquidation( - uint256 _amount - ) public { - _amount = bound(_amount, minFuzzAmount, maxFuzzAmount); - - // Collateral is transferred from troveManager via kick - airdrop(address(collateralToken), address(troveManager), _amount); - - uint256 _nonceBefore = dutchDesk.nonce(); - - // Liquidations: receiver=LENDER, maximum_amount=0, is_liquidation=true - vm.prank(address(troveManager)); - dutchDesk.kick(_amount, 0, address(lender), true); - - uint256 _auctionId = _nonceBefore; - - assertTrue(auction.is_active(_auctionId), "E0"); - assertEq(auction.get_available_amount(_auctionId), _amount, "E1"); - - IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - uint256 _expectedStartingPrice = - _amount * priceOracle.get_price(false) / WAD * dutchDesk.liquidation_starting_price_buffer_percentage() / COLLATERAL_TOKEN_PRECISION; - assertApproxEqAbs(auctionInfo.startingPrice, _expectedStartingPrice, 3, "E2"); - - uint256 _expectedMinimumPrice = priceOracle.get_price(false) * dutchDesk.liquidation_minimum_price_buffer_percentage() / WAD; - assertEq(auctionInfo.minimumPrice, _expectedMinimumPrice, "E3"); - - assertEq(auctionInfo.receiver, address(lender), "E4"); - assertEq(auctionInfo.surplusReceiver, address(lender), "E5"); - assertTrue(auctionInfo.isLiquidation, "E6"); - assertEq(dutchDesk.nonce(), _nonceBefore + 1, "E7"); - assertTrue(auction.is_ongoing_liquidation_auction(), "E8"); - assertEq(auction.liquidation_auctions(), 1, "E9"); - assertEq(auctionInfo.maximumAmount, 0, "E10"); - } - - function test_kick_redemption( + function test_kick( uint256 _amount, uint256 _maximumAmount, address _receiver @@ -104,7 +64,7 @@ contract DutchDeskTests is Base { uint256 _nonceBefore = dutchDesk.nonce(); vm.prank(address(troveManager)); - dutchDesk.kick(_amount, _maximumAmount, _receiver, false); + dutchDesk.kick(_amount, _maximumAmount, _receiver); uint256 _auctionId = _nonceBefore; @@ -113,55 +73,51 @@ contract DutchDeskTests is Base { IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); uint256 _expectedStartingPrice = - _amount * priceOracle.get_price(false) * dutchDesk.redemption_starting_price_buffer_percentage() / WAD / COLLATERAL_TOKEN_PRECISION; - assertEq(auctionInfo.startingPrice, _expectedStartingPrice, "E2"); + _amount * priceOracle.get_price(false) * dutchDesk.starting_price_buffer_percentage() / WAD / COLLATERAL_TOKEN_PRECISION; + assertEq(auctionInfo.starting_price, _expectedStartingPrice, "E2"); - uint256 _expectedMinimumPrice = priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD; - assertEq(auctionInfo.minimumPrice, _expectedMinimumPrice, "E3"); + uint256 _expectedMinimumPrice = priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD; + assertEq(auctionInfo.minimum_price, _expectedMinimumPrice, "E3"); assertEq(auctionInfo.receiver, _receiver, "E4"); - assertEq(auctionInfo.surplusReceiver, address(lender), "E5"); - assertFalse(auctionInfo.isLiquidation, "E6"); - assertEq(dutchDesk.nonce(), _nonceBefore + 1, "E7"); - assertFalse(auction.is_ongoing_liquidation_auction(), "E8"); - assertEq(auction.liquidation_auctions(), 0, "E9"); - assertEq(auctionInfo.maximumAmount, _maximumAmount, "E10"); + assertEq(auctionInfo.surplus_receiver, address(lender), "E5"); + assertEq(dutchDesk.nonce(), _nonceBefore + 1, "E6"); + assertEq(auctionInfo.maximum_amount, _maximumAmount, "E7"); } function test_kick_zeroAmount( - address _receiver, - bool _isLiquidation + address _receiver ) public { uint256 _nonceBefore = dutchDesk.nonce(); vm.prank(address(troveManager)); - dutchDesk.kick(0, 0, _receiver, _isLiquidation); + dutchDesk.kick(0, 0, _receiver); // Nothing should happen - nonce unchanged, no active auction assertEq(dutchDesk.nonce(), _nonceBefore, "E0"); assertFalse(auction.is_active(_nonceBefore), "E1"); } - function test_kick_multipleAuctions( + function test_multiple_kicks( uint256 _amount1, uint256 _amount2 ) public { _amount1 = bound(_amount1, minFuzzAmount, maxFuzzAmount / 2); _amount2 = bound(_amount2, minFuzzAmount, maxFuzzAmount / 2); - // First kick (liquidation) + // First kick (redemption) airdrop(address(collateralToken), address(troveManager), _amount1); vm.prank(address(troveManager)); - dutchDesk.kick(_amount1, 0, address(lender), true); + dutchDesk.kick(_amount1, type(uint256).max, address(userLender)); assertTrue(auction.is_active(0), "E0"); assertEq(auction.get_available_amount(0), _amount1, "E1"); assertEq(dutchDesk.nonce(), 1, "E2"); - // Second kick creates a new auction with nonce 1 (liquidation) + // Second kick creates a new auction with nonce 1 (redemption) airdrop(address(collateralToken), address(troveManager), _amount2); vm.prank(address(troveManager)); - dutchDesk.kick(_amount2, 0, address(lender), true); + dutchDesk.kick(_amount2, type(uint256).max, address(userLender)); assertTrue(auction.is_active(1), "E3"); assertEq(auction.get_available_amount(1), _amount2, "E4"); @@ -169,7 +125,6 @@ contract DutchDeskTests is Base { // First auction is still active assertTrue(auction.is_active(0), "E6"); - assertEq(auction.liquidation_auctions(), 2, "E7"); } function test_reKick( @@ -182,7 +137,7 @@ contract DutchDeskTests is Base { // Kick a redemption auction airdrop(address(collateralToken), address(troveManager), _amount); vm.prank(address(troveManager)); - dutchDesk.kick(_amount, type(uint256).max, _receiver, false); + dutchDesk.kick(_amount, type(uint256).max, _receiver); uint256 _auctionId = 0; assertTrue(auction.is_active(_auctionId), "E0"); @@ -199,14 +154,14 @@ contract DutchDeskTests is Base { assertTrue(auction.is_active(_auctionId), "E3"); assertEq(auction.get_available_amount(_auctionId), _amount, "E4"); - // Starting price should use EMERGENCY buffer + // Starting price should use re-kick buffer IAuction.AuctionInfo memory auctionInfo = auction.auctions(_auctionId); - uint256 _expectedStartingPrice = _amount * priceOracle.get_price(false) * dutchDesk.redemption_re_kick_starting_price_buffer_percentage() - / WAD / COLLATERAL_TOKEN_PRECISION; - assertEq(auctionInfo.startingPrice, _expectedStartingPrice, "E5"); + uint256 _expectedStartingPrice = + _amount * priceOracle.get_price(false) * dutchDesk.re_kick_starting_price_buffer_percentage() / WAD / COLLATERAL_TOKEN_PRECISION; + assertEq(auctionInfo.starting_price, _expectedStartingPrice, "E5"); - uint256 _expectedMinimumPrice = priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD; - assertEq(auctionInfo.minimumPrice, _expectedMinimumPrice, "E6"); + uint256 _expectedMinimumPrice = priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD; + assertEq(auctionInfo.minimum_price, _expectedMinimumPrice, "E6"); } function test_reKick_auctionStillActive( @@ -219,7 +174,7 @@ contract DutchDeskTests is Base { // Kick a redemption auction airdrop(address(collateralToken), address(troveManager), _amount); vm.prank(address(troveManager)); - dutchDesk.kick(_amount, type(uint256).max, _receiver, false); + dutchDesk.kick(_amount, type(uint256).max, _receiver); assertTrue(auction.is_active(0), "E0"); diff --git a/test/Gas.t.sol b/test/Gas.t.sol index 1793709..e50bf17 100644 --- a/test/Gas.t.sol +++ b/test/Gas.t.sol @@ -89,42 +89,4 @@ contract GasTests is Base { assertLt(gasUsed, MAX_GAS, "Exceeded 7M gas limit"); } - function test_gas_liquidateTroves() public { - uint256 _minDebt = troveManager.min_debt(); - uint256 _rate = DEFAULT_ANNUAL_INTEREST_RATE; - uint256 _numTroves = MAX_LIQUIDATIONS; - - uint256 _lenderDeposit = _minDebt * _numTroves; - - // Fund lender - mintAndDepositIntoLender(userLender, _lenderDeposit); - - // Create troves and store their IDs - uint256[MAX_LIQUIDATIONS] memory _troveIds; - for (uint256 i = 0; i < _numTroves; i++) { - uint256 _collateralNeeded = - (_minDebt * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); - - address _user = address(uint160(i + 1000)); - _troveIds[i] = mintAndOpenTrove(_user, _collateralNeeded, _minDebt, _rate); - } - - // Drop price to make all troves liquidatable - uint256 _priceDropToBelowMCR = priceOracle.get_price() * 80 / 100; // 20% drop - uint256 _priceDropToBelowMCR18 = priceOracle.get_price(false) * 80 / 100; // 20% drop - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceDropToBelowMCR)); - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), abi.encode(_priceDropToBelowMCR18)); - - // Liquidate all troves - uint256 _gasBefore = gasleft(); - troveManager.liquidate_troves(_troveIds); - uint256 _gasUsed = _gasBefore - gasleft(); - - emit log_named_uint("Troves liquidated", _numTroves); - emit log_named_uint("Gas used", _gasUsed); - emit log_named_uint("Cost in ETH (wei)", _gasUsed * GAS_PRICE); - - assertLt(_gasUsed, MAX_GAS + 1_000_000, "Exceeded 8M gas limit"); - } - } diff --git a/test/LaggingOracleValueExtractionPOC.t.sol b/test/LaggingOracleValueExtractionPOC.t.sol index a475798..ebecc97 100644 --- a/test/LaggingOracleValueExtractionPOC.t.sol +++ b/test/LaggingOracleValueExtractionPOC.t.sol @@ -33,7 +33,7 @@ contract LaggingOracleValueExtractionPOC is Base { // Max market premium must be less than starting price buffer, otherwise attacker can still profit // STARTING_PRICE_BUFFER_PERCENTAGE is 1e18 + buffer%, e.g. 1.15e18 for 15% - uint256 _startingBufferBps = (dutchDesk.redemption_starting_price_buffer_percentage() - WAD) / 1e14; + uint256 _startingBufferBps = (dutchDesk.starting_price_buffer_percentage() - WAD) / 1e14; _marketPremiumBps = bound(_marketPremiumBps, 10, _startingBufferBps - 1); // Lend liquidity @@ -61,7 +61,7 @@ contract LaggingOracleValueExtractionPOC is Base { assertEq(auction.auctions(0).receiver, userBorrower, "E0"); uint256 _auctionCollateral = auction.get_available_amount(0); - uint256 _maximumAmount = auction.auctions(0).maximumAmount; + uint256 _maximumAmount = auction.auctions(0).maximum_amount; // Track lender balance before auction take uint256 _lenderBalanceBefore = borrowToken.balanceOf(address(lender)); diff --git a/test/Lend.t.sol b/test/Lend.t.sol index 8ccef8d..c770cfa 100644 --- a/test/Lend.t.sol +++ b/test/Lend.t.sol @@ -1,9 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {IPriceOracleNotScaled} from "./interfaces/IPriceOracleNotScaled.sol"; -import {IPriceOracleScaled} from "./interfaces/IPriceOracleScaled.sol"; - import "./Base.sol"; contract LendTests is Base { @@ -28,7 +25,6 @@ contract LendTests is Base { } function test_setup() public { - assertEq(address(lender.AUCTION()), address(auction), "E0"); assertEq(address(lender.TROVE_MANAGER()), address(troveManager), "E1"); assertEq(lender.depositLimit(), type(uint256).max, "E2"); assertEq(lender.availableWithdrawLimit(userLender), type(uint256).max, "E3"); @@ -442,163 +438,4 @@ contract LendTests is Base { lender.setDepositLimit(_depositLimit); } - function test_availableWithdrawLimit_duringLiquidation( - uint256 _amount, - uint256 _idleLiquidity - ) public { - _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); - _idleLiquidity = bound(_idleLiquidity, minFuzzAmount, maxFuzzAmount); - - // Lend some from lender - mintAndDepositIntoLender(userLender, _amount); - - // Lend from another user so we have some idle liquidity after borrow - address _anotherUserLender = address(0xBEEF); - - // Lend some from lender - mintAndDepositIntoLender(_anotherUserLender, _idleLiquidity); - - // Calculate how much collateral is needed for the borrow amount - uint256 _collateralNeeded = - (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); - - // Open a trove - uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); - - // Get trove info - ITroveManager.Trove memory _trove = troveManager.troves(_troveId); - - // No ongoing liquidation yet - assertEq(lender.availableWithdrawLimit(userLender), type(uint256).max, "E0"); - assertFalse(auction.is_ongoing_liquidation_auction(), "E1"); - - // Calculate price drop to put trove below MCR (1% below) - uint256 _priceDropToBelowMCR; - if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { - _priceDropToBelowMCR = - troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); - } else { - _priceDropToBelowMCR = - troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; - } - uint256 _priceDropToBelowMCR18 = _priceDropToBelowMCR * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION); - - // Mock the oracle price - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceDropToBelowMCR)); - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), abi.encode(_priceDropToBelowMCR18)); - - // Liquidate the trove - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId; - troveManager.liquidate_troves(_troveIdsToLiquidate); - - // Liquidation auction is now ongoing - assertTrue(auction.is_ongoing_liquidation_auction(), "E2"); - - // Available withdraw limit should be only idle assets during liquidation - assertEq(lender.availableWithdrawLimit(userLender), _idleLiquidity, "E3"); - - // Take the liquidation auction - takeAuction(0); - - // After auction is complete, no more ongoing liquidation - assertFalse(auction.is_ongoing_liquidation_auction(), "E4"); - - // Available withdraw limit should be max again - assertEq(lender.availableWithdrawLimit(userLender), type(uint256).max, "E5"); - } - - function test_shutdownCanWithdraw( - uint256 _amount - ) public { - _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); - - // Lend some from lender - mintAndDepositIntoLender(userLender, _amount); - - assertEq(lender.totalAssets(), _amount, "E0"); - - // Calculate how much collateral is needed for the borrow amount - uint256 _collateralNeeded = - (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); - - // Open a trove - mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); - - // Skip some time - skip(1 days); - - // Shutdown the strategy - vm.prank(management); - lender.shutdownStrategy(); - - // Available withdraw limit should still be max when shutdown - assertEq(lender.availableWithdrawLimit(userLender), type(uint256).max, "E1"); - - // Make sure we can still withdraw the full amount - uint256 _balanceBefore = borrowToken.balanceOf(userLender); - - // Withdraw all funds - vm.prank(userLender); - lender.redeem(_amount, userLender, userLender); - - // Take the auction - takeAuction(0); - - // No report, no profit, loss bc `takeAuction` pricing is not perfect - assertApproxEqRel(borrowToken.balanceOf(userLender), _balanceBefore + _amount, 5e15, "E31"); // 0.5% - } - - function test_shutdownCanWithdraw_duringLiquidation( - uint256 _amount - ) public { - _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); - - // Lend some from lender - mintAndDepositIntoLender(userLender, _amount); - - // Calculate how much collateral is needed for the borrow amount - uint256 _collateralNeeded = - (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); - - // Open a trove - uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); - - // Get trove info - ITroveManager.Trove memory _trove = troveManager.troves(_troveId); - - // Calculate price drop to put trove below MCR (1% below) - uint256 _priceDropToBelowMCR; - if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { - _priceDropToBelowMCR = - troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); - } else { - _priceDropToBelowMCR = - troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; - } - uint256 _priceDropToBelowMCR18 = _priceDropToBelowMCR * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION); - - // Mock the oracle price - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceDropToBelowMCR)); - vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), abi.encode(_priceDropToBelowMCR18)); - - // Liquidate the trove - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId; - troveManager.liquidate_troves(_troveIdsToLiquidate); - - // Liquidation auction is now ongoing - assertTrue(auction.is_ongoing_liquidation_auction(), "E0"); - - // Available withdraw limit should be 0 during liquidation - assertEq(lender.availableWithdrawLimit(userLender), 0, "E1"); - - // Shutdown the strategy - vm.prank(management); - lender.shutdownStrategy(); - - // When shutdown, available withdraw limit should be max even during liquidation - assertEq(lender.availableWithdrawLimit(userLender), type(uint256).max, "E2"); - } - } diff --git a/test/Liquidate.t.sol b/test/Liquidate.t.sol index 665b697..c0e4424 100644 --- a/test/Liquidate.t.sol +++ b/test/Liquidate.t.sol @@ -26,7 +26,8 @@ contract LiquidateTests is Base { function test_liquidateTrove( uint256 _amount ) public { - _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); + // Bound near min_debt to ensure safe CR cap + min_debt check forces full liquidation + _amount = bound(_amount, troveManager.min_debt(), troveManager.min_debt() * 13 / 10); // Lend some from lender mintAndDepositIntoLender(userLender, _amount); @@ -110,73 +111,57 @@ contract LiquidateTests is Base { // Make sure Trove is below MCR assertLt(_troveCollateralRatioAfter, troveManager.minimum_collateral_ratio(), "E26"); - // Finally, liquidate the trove - { - vm.startPrank(liquidator); - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId; - troveManager.liquidate_troves(_troveIdsToLiquidate); - vm.stopPrank(); - } - - // Check liquidator received no fee (all collateral goes to auction) - assertEq(collateralToken.balanceOf(liquidator), 0, "E26a"); - - // Check auction starting price and minimum price - { - uint256 _expectedStartingPrice = _collateralNeeded * _priceDropToBelowMCR18 * dutchDesk.liquidation_starting_price_buffer_percentage() - / 1e18 / COLLATERAL_TOKEN_PRECISION; - assertEq(auction.auctions(0).startingPrice, _expectedStartingPrice, "E27"); - uint256 _expectedMinimumPrice = _priceDropToBelowMCR18 * dutchDesk.liquidation_minimum_price_buffer_percentage() / WAD; - assertEq(auction.auctions(0).minimumPrice, _expectedMinimumPrice, "E28"); - } + // Calculate expected collateral to decrease + uint256 _expectedCollateralToDecrease = + calculateCollateralToDecrease(_troveCollateralRatioAfter, _expectedDebt, _priceDropToBelowMCR, _collateralNeeded); + uint256 _expectedRemainingCollateral = _collateralNeeded - _expectedCollateralToDecrease; - // Take the auction - takeAuction(0); + // Finally, liquidate the trove + liquidate(_troveId); - // Make sure lender got all the borrow tokens back + liquidation fee - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E29"); + // Make sure lender got all the borrow tokens back + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E27"); - // Make sure liquidator got the collateral - assertEq(collateralToken.balanceOf(liquidator), _collateralNeeded, "E30"); + // Make sure liquidator mock got the collateral + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateralToDecrease, "E28"); // Check everything again // Check trove info _trove = troveManager.troves(_troveId); - assertEq(_trove.debt, 0, "E31"); - assertEq(_trove.collateral, 0, "E32"); - assertEq(_trove.annual_interest_rate, 0, "E33"); - assertEq(_trove.last_debt_update_time, 0, "E34"); - assertEq(_trove.last_interest_rate_adj_time, 0, "E35"); - assertEq(_trove.owner, address(0), "E36"); - assertEq(_trove.pending_owner, address(0), "E37"); - assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E38"); + assertEq(_trove.debt, 0, "E29"); + assertEq(_trove.collateral, 0, "E30"); + assertEq(_trove.annual_interest_rate, 0, "E31"); + assertEq(_trove.last_debt_update_time, 0, "E32"); + assertEq(_trove.last_interest_rate_adj_time, 0, "E33"); + assertEq(_trove.owner, address(0), "E34"); + assertEq(_trove.pending_owner, address(0), "E35"); + assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E36"); // Check sorted troves - assertTrue(sortedTroves.empty(), "E39"); - assertEq(sortedTroves.size(), 0, "E40"); - assertEq(sortedTroves.first(), 0, "E41"); - assertEq(sortedTroves.last(), 0, "E42"); - assertFalse(sortedTroves.contains(_troveId), "E43"); + assertTrue(sortedTroves.empty(), "E37"); + assertEq(sortedTroves.size(), 0, "E38"); + assertEq(sortedTroves.first(), 0, "E39"); + assertEq(sortedTroves.last(), 0, "E40"); + assertFalse(sortedTroves.contains(_troveId), "E41"); // Check balances - assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E44"); - assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E45"); - assertEq(collateralToken.balanceOf(address(userBorrower)), 0, "E46"); - assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E47"); - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E48"); - assertEq(borrowToken.balanceOf(userBorrower), _amount, "E49"); + assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E42"); + assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E43"); + assertEq(collateralToken.balanceOf(address(userBorrower)), _expectedRemainingCollateral, "E44"); + assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E45"); + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E46"); + assertEq(borrowToken.balanceOf(userBorrower), _amount, "E47"); // Check global info - assertEq(troveManager.total_debt(), 0, "E50"); - assertEq(troveManager.total_weighted_debt(), 0, "E51"); - assertEq(troveManager.collateral_balance(), 0, "E52"); - assertEq(troveManager.zombie_trove_id(), 0, "E53"); + assertEq(troveManager.total_debt(), 0, "E48"); + assertEq(troveManager.total_weighted_debt(), 0, "E49"); + assertEq(troveManager.collateral_balance(), 0, "E50"); + assertEq(troveManager.zombie_trove_id(), 0, "E51"); // Check dutch desk is empty - assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E54"); - assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E55"); + assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E52"); + assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E53"); } // 1. lend @@ -187,7 +172,8 @@ contract LiquidateTests is Base { function test_liquidateTroves( uint256 _amount ) public { - _amount = bound(_amount, troveManager.min_debt() * 2, maxFuzzAmount); + // Bound near min_debt to ensure safe CR cap + min_debt check forces full liquidation per trove + _amount = bound(_amount, troveManager.min_debt() * 2, troveManager.min_debt() * 26 / 10); // Lend some from lender mintAndDepositIntoLender(userLender, _amount); @@ -318,127 +304,108 @@ contract LiquidateTests is Base { // Make sure Trove is below MCR assertLt(_troveCollateralRatioAfter, troveManager.minimum_collateral_ratio(), "E52"); - // Finally, liquidate the troves - { - vm.startPrank(liquidator); - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId; - _troveIdsToLiquidate[1] = _anotherTroveId; - troveManager.liquidate_troves(_troveIdsToLiquidate); - vm.stopPrank(); - } - - // Check liquidator received no fee (all collateral goes to auction) - assertEq(collateralToken.balanceOf(liquidator), 0, "E53"); - - // Check auction starting price and minimum price - // Starting price = available * price * STARTING_PRICE_BUFFER_PERCENTAGE / 1e18 / COLLATERAL_TOKEN_PRECISION - // Note: both troves liquidated in same tx, so all collateral goes to auction - { - uint256 _collateralToSell = _collateralNeeded * 2; - uint256 _expectedStartingPrice = _collateralToSell * _priceDropToBelowMCR18 * dutchDesk.liquidation_starting_price_buffer_percentage() - / 1e18 / COLLATERAL_TOKEN_PRECISION; - assertEq(auction.auctions(0).startingPrice, _expectedStartingPrice, "E54"); - uint256 _expectedMinimumPrice = _priceDropToBelowMCR18 * dutchDesk.liquidation_minimum_price_buffer_percentage() / WAD; - assertEq(auction.auctions(0).minimumPrice, _expectedMinimumPrice, "E55"); - } + // Calculate expected collateral to decrease (same for both troves) + uint256 _expectedCollateralToDecrease = + calculateCollateralToDecrease(_troveCollateralRatioAfter, _expectedDebt, _priceDropToBelowMCR, _collateralNeeded); + uint256 _expectedRemainingCollateral = _collateralNeeded - _expectedCollateralToDecrease; - // Take the auction - takeAuction(0); + // Finally, liquidate both troves + liquidate(_troveId); + liquidate(_anotherTroveId); - // Make sure lender got all the borrow tokens back + liquidation fee - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt * 2, "E56"); + // Make sure lender got all the borrow tokens back + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt * 2, "E53"); - // Make sure liquidator got the collateral - assertEq(collateralToken.balanceOf(liquidator), _collateralNeeded * 2, "E57"); + // Make sure liquidator mock got the collateral + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateralToDecrease * 2, "E54"); // Check everything again // Check trove info _trove = troveManager.troves(_troveId); - assertEq(_trove.debt, 0, "E58"); - assertEq(_trove.collateral, 0, "E59"); - assertEq(_trove.annual_interest_rate, 0, "E60"); - assertEq(_trove.last_debt_update_time, 0, "E61"); - assertEq(_trove.last_interest_rate_adj_time, 0, "E62"); - assertEq(_trove.owner, address(0), "E63"); - assertEq(_trove.pending_owner, address(0), "E64"); - assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E65"); + assertEq(_trove.debt, 0, "E55"); + assertEq(_trove.collateral, 0, "E56"); + assertEq(_trove.annual_interest_rate, 0, "E57"); + assertEq(_trove.last_debt_update_time, 0, "E58"); + assertEq(_trove.last_interest_rate_adj_time, 0, "E59"); + assertEq(_trove.owner, address(0), "E60"); + assertEq(_trove.pending_owner, address(0), "E61"); + assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E62"); // Check sorted troves - assertTrue(sortedTroves.empty(), "E66"); - assertEq(sortedTroves.size(), 0, "E67"); - assertEq(sortedTroves.first(), 0, "E68"); - assertEq(sortedTroves.last(), 0, "E69"); - assertFalse(sortedTroves.contains(_troveId), "E70"); + assertTrue(sortedTroves.empty(), "E63"); + assertEq(sortedTroves.size(), 0, "E64"); + assertEq(sortedTroves.first(), 0, "E65"); + assertEq(sortedTroves.last(), 0, "E66"); + assertFalse(sortedTroves.contains(_troveId), "E67"); // Check balances - assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E71"); - assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E72"); - assertEq(collateralToken.balanceOf(address(userBorrower)), 0, "E73"); - assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E74"); - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E75"); - assertEq(borrowToken.balanceOf(userBorrower), _halfAmount, "E76"); + assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E68"); + assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E69"); + assertEq(collateralToken.balanceOf(address(userBorrower)), _expectedRemainingCollateral, "E70"); + assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E71"); + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E72"); + assertEq(borrowToken.balanceOf(userBorrower), _halfAmount, "E73"); // Check global info - assertEq(troveManager.total_debt(), 0, "E77"); - assertEq(troveManager.total_weighted_debt(), 0, "E78"); - assertEq(troveManager.collateral_balance(), 0, "E79"); - assertEq(troveManager.zombie_trove_id(), 0, "E80"); + assertEq(troveManager.total_debt(), 0, "E74"); + assertEq(troveManager.total_weighted_debt(), 0, "E75"); + assertEq(troveManager.collateral_balance(), 0, "E76"); + assertEq(troveManager.zombie_trove_id(), 0, "E77"); // Check dutch desk is empty - assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E81"); - assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E82"); + assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E78"); + assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E79"); // Check everything again for the second trove // Check trove info _trove = troveManager.troves(_anotherTroveId); - assertEq(_trove.debt, 0, "E83"); - assertEq(_trove.collateral, 0, "E84"); - assertEq(_trove.annual_interest_rate, 0, "E85"); - assertEq(_trove.last_debt_update_time, 0, "E86"); - assertEq(_trove.last_interest_rate_adj_time, 0, "E87"); - assertEq(_trove.owner, address(0), "E88"); - assertEq(_trove.pending_owner, address(0), "E89"); - assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E90"); + assertEq(_trove.debt, 0, "E80"); + assertEq(_trove.collateral, 0, "E81"); + assertEq(_trove.annual_interest_rate, 0, "E82"); + assertEq(_trove.last_debt_update_time, 0, "E83"); + assertEq(_trove.last_interest_rate_adj_time, 0, "E84"); + assertEq(_trove.owner, address(0), "E85"); + assertEq(_trove.pending_owner, address(0), "E86"); + assertEq(uint256(_trove.status), uint256(ITroveManager.Status.liquidated), "E87"); // Check sorted troves - assertTrue(sortedTroves.empty(), "E91"); - assertEq(sortedTroves.size(), 0, "E92"); - assertEq(sortedTroves.first(), 0, "E93"); - assertEq(sortedTroves.last(), 0, "E94"); - assertFalse(sortedTroves.contains(_anotherTroveId), "E95"); + assertTrue(sortedTroves.empty(), "E88"); + assertEq(sortedTroves.size(), 0, "E89"); + assertEq(sortedTroves.first(), 0, "E90"); + assertEq(sortedTroves.last(), 0, "E91"); + assertFalse(sortedTroves.contains(_anotherTroveId), "E92"); // Check balances - assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E96"); - assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E97"); - assertEq(collateralToken.balanceOf(address(userBorrower)), 0, "E98"); - assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E99"); - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E100"); - assertEq(borrowToken.balanceOf(userBorrower), _halfAmount, "E101"); + assertEq(collateralToken.balanceOf(address(troveManager)), 0, "E93"); + assertEq(collateralToken.balanceOf(address(troveManager)), troveManager.collateral_balance(), "E94"); + assertEq(collateralToken.balanceOf(address(userBorrower)), _expectedRemainingCollateral, "E95"); + assertEq(borrowToken.balanceOf(address(troveManager)), 0, "E96"); + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt, "E97"); + assertEq(borrowToken.balanceOf(userBorrower), _halfAmount, "E98"); // Check global info - assertEq(troveManager.total_debt(), 0, "E102"); - assertEq(troveManager.total_weighted_debt(), 0, "E103"); - assertEq(troveManager.collateral_balance(), 0, "E104"); - assertEq(troveManager.zombie_trove_id(), 0, "E105"); + assertEq(troveManager.total_debt(), 0, "E99"); + assertEq(troveManager.total_weighted_debt(), 0, "E100"); + assertEq(troveManager.collateral_balance(), 0, "E101"); + assertEq(troveManager.zombie_trove_id(), 0, "E102"); // Check dutch desk is empty - assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E106"); - assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E107"); + assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E103"); + assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E104"); } // 1. lend // 2. open 2 troves // 3. collateral price drops // 4. liquidate first trove - // 5. liquidate second trove (before taking the first auction) - // 6. take the auction (should have collateral from both troves - DutchDesk sweeps + settles + re-kicks with all collateral) + // 5. liquidate second trove function test_liquidateTroves_sequentialLiquidations( uint256 _amount ) public { - _amount = bound(_amount, troveManager.min_debt() * 2, maxFuzzAmount); + // Bound near min_debt to ensure safe CR cap + min_debt check forces full liquidation per trove + _amount = bound(_amount, troveManager.min_debt() * 2, troveManager.min_debt() * 26 / 10); // Lend some from lender mintAndDepositIntoLender(userLender, _amount); @@ -505,113 +472,265 @@ contract LiquidateTests is Base { "E8" ); + // Calculate expected collateral to decrease (same for both troves) + uint256 _troveCollateralRatioAfter = + (_trove1.collateral * priceOracle.get_price() / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove1.debt; + uint256 _expectedCollateralToDecrease = + calculateCollateralToDecrease(_troveCollateralRatioAfter, _expectedDebt, _priceDropToBelowMCR, _collateralNeeded); + // Liquidate the first trove - vm.startPrank(liquidator); - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId1; - troveManager.liquidate_troves(_troveIdsToLiquidate); - vm.stopPrank(); - - // Check liquidator received no fee (all collateral goes to auction) - assertEq(collateralToken.balanceOf(liquidator), 0, "E9"); - - // Check auction 0 has all collateral from first trove - // uint256 _auctionId0 = 0; - { - uint256 _collateralInAuction = _collateralNeeded; - assertTrue(auction.is_active(0), "E10"); - assertEq(auction.get_available_amount(0), _collateralInAuction, "E11"); - assertEq(collateralToken.balanceOf(address(auction)), _collateralInAuction, "E12"); - - // Check auction starting price and minimum price after first liquidation - assertEq( - auction.auctions(0).startingPrice, - _collateralInAuction * _priceDropToBelowMCR18 * dutchDesk.liquidation_starting_price_buffer_percentage() / 1e18 - / COLLATERAL_TOKEN_PRECISION, - "E13" - ); - assertEq(auction.auctions(0).minimumPrice, _priceDropToBelowMCR18 * dutchDesk.liquidation_minimum_price_buffer_percentage() / WAD, "E14"); - } + liquidate(_troveId1); + + // Check liquidator mock received collateral from first trove + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateralToDecrease, "E9"); // Check first trove is liquidated - _trove1 = troveManager.troves(_troveId1); - assertEq(uint256(troveManager.troves(_troveId1).status), uint256(ITroveManager.Status.liquidated), "E15"); + assertEq(uint256(troveManager.troves(_troveId1).status), uint256(ITroveManager.Status.liquidated), "E10"); // Check second trove is still active - _trove2 = troveManager.troves(_troveId2); - assertEq(uint256(_trove2.status), uint256(ITroveManager.Status.active), "E16"); + assertEq(uint256(troveManager.troves(_troveId2).status), uint256(ITroveManager.Status.active), "E11"); - // Liquidate the second trove (before taking the first auction) - // In new architecture, this creates a separate auction with ID 1 - vm.startPrank(liquidator); - _troveIdsToLiquidate[0] = _troveId2; - troveManager.liquidate_troves(_troveIdsToLiquidate); - vm.stopPrank(); - - // Check liquidator received no fees (all collateral goes to auction) - assertEq(collateralToken.balanceOf(liquidator), 0, "E17"); + // Liquidate the second trove + liquidate(_troveId2); // Check second trove is now liquidated - _trove2 = troveManager.troves(_troveId2); - assertEq(uint256(_trove2.status), uint256(ITroveManager.Status.liquidated), "E18"); - - // Check auction 1 has all collateral from second trove (separate auction) - // uint256 _auctionId1 = 1; - { - uint256 _collateralInAuction = _collateralNeeded; - assertTrue(auction.is_active(1), "E19"); - assertEq(auction.get_available_amount(1), _collateralInAuction, "E20"); - assertEq(collateralToken.balanceOf(address(auction)), _collateralInAuction * 2, "E21"); - - // Check auction 0 is still active with first trove's collateral - assertTrue(auction.is_active(0), "E22"); - assertEq(auction.get_available_amount(0), _collateralInAuction, "E23"); + assertEq(uint256(troveManager.troves(_troveId2).status), uint256(ITroveManager.Status.liquidated), "E12"); + + // Make sure lender got all the borrow tokens back + assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt * 2, "E13"); + + // Make sure liquidator mock got all the collateral + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateralToDecrease * 2, "E14"); + + // Check dutch desk is empty + assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E15"); + assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E16"); + + // Check global info + assertEq(troveManager.total_debt(), 0, "E17"); + assertEq(troveManager.total_weighted_debt(), 0, "E18"); + assertEq(troveManager.collateral_balance(), 0, "E19"); + } + + // Liquidate 4 troves at different price levels to test fee scaling: + // 1. Near MCR --> min fee (0.5%) + // 2. Midpoint between MCR and max penalty CR --> interpolated fee (~max/2) + // 3. At max penalty CR --> max fee (5%) + // 4. Price back up near MCR --> min fee (0.5%) again + function test_liquidateTrove_feeScaling() public { + uint256 _minDebt = troveManager.min_debt(); + uint256 _collateralNeeded = + (_minDebt * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + mintAndDepositIntoLender(userLender, _minDebt * 4); + + uint256 _troveId1 = mintAndOpenTrove(address(1001), _collateralNeeded, _minDebt, DEFAULT_ANNUAL_INTEREST_RATE); + uint256 _troveId2 = mintAndOpenTrove(address(1002), _collateralNeeded, _minDebt, DEFAULT_ANNUAL_INTEREST_RATE); + uint256 _troveId3 = mintAndOpenTrove(address(1003), _collateralNeeded, _minDebt, DEFAULT_ANNUAL_INTEREST_RATE); + uint256 _troveId4 = mintAndOpenTrove(address(1004), _collateralNeeded, _minDebt, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId1); + uint256 _mcr = troveManager.minimum_collateral_ratio(); + uint256 _maxPenaltyCR = troveManager.max_penalty_collateral_ratio(); + + // Price levels for different fee tiers + uint256 _priceNearMCR = (_mcr - 1) * _trove.debt * ORACLE_PRICE_SCALE / (_trove.collateral * BORROW_TOKEN_PRECISION); + uint256 _priceMid = ((_mcr + _maxPenaltyCR) / 2) * _trove.debt * ORACLE_PRICE_SCALE / (_trove.collateral * BORROW_TOKEN_PRECISION); + uint256 _priceMaxPenalty = _maxPenaltyCR * _trove.debt * ORACLE_PRICE_SCALE / (_trove.collateral * BORROW_TOKEN_PRECISION); + + uint256 _balBefore; + uint256 _baseCollateral; + + // --- Trove 1: CR just below MCR --> min fee (0.5%) --- + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceNearMCR)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_priceNearMCR * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + _balBefore = collateralToken.balanceOf(address(liquidatorMock)); + liquidate(_troveId1); + uint256 _received1 = collateralToken.balanceOf(address(liquidatorMock)) - _balBefore; + _baseCollateral = _trove.debt * ORACLE_PRICE_SCALE / _priceNearMCR; + assertApproxEqRel( + _received1, _baseCollateral * (BORROW_TOKEN_PRECISION + troveManager.min_liquidation_fee()) / BORROW_TOKEN_PRECISION, 1e15, "E0" + ); + + // --- Trove 2: CR at midpoint --> interpolated fee (~max/2) --- + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceMid)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_priceMid * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + _balBefore = collateralToken.balanceOf(address(liquidatorMock)); + liquidate(_troveId2); + uint256 _received2 = collateralToken.balanceOf(address(liquidatorMock)) - _balBefore; + _baseCollateral = _trove.debt * ORACLE_PRICE_SCALE / _priceMid; + assertApproxEqRel( + _received2, _baseCollateral * (BORROW_TOKEN_PRECISION + troveManager.max_liquidation_fee() / 2) / BORROW_TOKEN_PRECISION, 1e16, "E1" + ); + + // --- Trove 3: CR at max penalty --> max fee (5%) --- + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceMaxPenalty)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_priceMaxPenalty * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + _balBefore = collateralToken.balanceOf(address(liquidatorMock)); + liquidate(_troveId3); + uint256 _received3 = collateralToken.balanceOf(address(liquidatorMock)) - _balBefore; + _baseCollateral = _trove.debt * ORACLE_PRICE_SCALE / _priceMaxPenalty; + assertEq(_received3, _baseCollateral * (BORROW_TOKEN_PRECISION + troveManager.max_liquidation_fee()) / BORROW_TOKEN_PRECISION, "E2"); + + // --- Trove 4: price back up near MCR --> min fee (0.5%) again --- + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceNearMCR)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_priceNearMCR * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + _balBefore = collateralToken.balanceOf(address(liquidatorMock)); + liquidate(_troveId4); + uint256 _received4 = collateralToken.balanceOf(address(liquidatorMock)) - _balBefore; + + // Verify fee scaling: more collateral at lower CRs + assertLt(_received1, _received2, "E3"); + assertLt(_received2, _received3, "E4"); + assertEq(_received1, _received4, "E5"); + } + + // Partial liquidation: liquidate 1/4 of the debt, trove stays open with improved CR + function test_liquidateTrove_partial( + uint256 _amount + ) public { + _amount = bound(_amount, troveManager.min_debt() * 2, maxFuzzAmount); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price to 1% below MCR + uint256 _price; + if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); + } else { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; } + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); - // Take both auctions - takeAuction(0); - takeAuction(1); + uint256 _crBefore = (_trove.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove.debt; + assertLt(_crBefore, troveManager.minimum_collateral_ratio(), "E0"); - // Both auctions should be empty now - assertEq(collateralToken.balanceOf(address(auction)), 0, "E24"); - assertFalse(auction.is_active(0), "E25"); - assertFalse(auction.is_active(1), "E26"); + // Partial liquidation: liquidate 1/4 of the debt + uint256 _debtToLiquidate = _trove.debt / 4; + uint256 _expectedCollateral = calculateCollateralToDecrease(_crBefore, _debtToLiquidate, _price, _trove.collateral); - // Make sure lender got all the borrow tokens back + liquidation fees - assertGe(borrowToken.balanceOf(address(lender)), _expectedDebt * 2, "E27"); + liquidatorMock.liquidate(_troveId, _debtToLiquidate); - // Make sure liquidator got all the collateral (fees + auction proceeds) - assertEq(collateralToken.balanceOf(liquidator), _collateralNeeded * 2, "E28"); + // Trove should still be active with reduced debt and collateral + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.active), "E1"); + assertEq(_troveAfter.debt, _trove.debt - _debtToLiquidate, "E2"); + assertEq(_troveAfter.collateral, _trove.collateral - _expectedCollateral, "E3"); + assertEq(_troveAfter.owner, userBorrower, "E4"); - // Check dutch desk is empty - assertEq(borrowToken.balanceOf(address(dutchDesk)), 0, "E29"); - assertEq(collateralToken.balanceOf(address(dutchDesk)), 0, "E30"); + // CR should have improved and be above MCR + uint256 _crAfter = (_troveAfter.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _troveAfter.debt; + assertGt(_crAfter, _crBefore, "E5"); + assertGe(_crAfter, troveManager.minimum_collateral_ratio(), "E6"); - // Check global info - assertEq(troveManager.total_debt(), 0, "E31"); - assertEq(troveManager.total_weighted_debt(), 0, "E32"); - assertEq(troveManager.collateral_balance(), 0, "E33"); + // Liquidator received correct collateral + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateral, "E7"); + + // Trove owner did not receive collateral (only happens on full liquidation) + assertEq(collateralToken.balanceOf(userBorrower), 0, "E8"); + + // Trove still in sorted list + assertTrue(sortedTroves.contains(_troveId), "E9"); } - function test_liquidateTroves_emptyList() public { - // Make sure we always fail when no trove ids are passed - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - vm.expectRevert("!trove_ids"); - troveManager.liquidate_troves(_troveIdsToLiquidate); + // Partial liquidation that triggers full: remaining debt would be below min_debt + function test_liquidateTrove_partialBecomesFullBelowMinDebt( + uint256 _amount + ) public { + // Bound near min_debt to ensure safe CR cap doesn't override the explicit partial amount + _amount = bound(_amount, troveManager.min_debt(), troveManager.min_debt() * 13 / 10); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price to 1% below MCR + uint256 _price; + if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); + } else { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; + } + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + + // Try partial: amount that would leave remaining below min_debt --> forced full + uint256 _partialDebt = _trove.debt - troveManager.min_debt() + 1; + + // Expected collateral for full liquidation (full debt, not partial) + uint256 _cr = (_trove.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove.debt; + uint256 _expectedCollateral = calculateCollateralToDecrease(_cr, _trove.debt, _price, _trove.collateral); + uint256 _expectedRemaining = _trove.collateral - _expectedCollateral; + + liquidatorMock.liquidate(_troveId, _partialDebt); + + // Trove should be fully liquidated (not partial) + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.liquidated), "E0"); + assertEq(_troveAfter.debt, 0, "E1"); + assertEq(_troveAfter.collateral, 0, "E2"); + + // Liquidator got the collateral_to_decrease (for full debt, not the partial amount) + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _expectedCollateral, "E3"); + + // Trove owner got the remaining collateral + assertEq(collateralToken.balanceOf(userBorrower), _expectedRemaining, "E4"); + + // Trove removed from sorted list + assertFalse(sortedTroves.contains(_troveId), "E5"); } - function test_liquidateTroves_nonExistentTrove( + function test_liquidateTrove_nonExistentTrove( uint256 _nonExistentTroveId ) public { _nonExistentTroveId = bound(_nonExistentTroveId, 1, type(uint256).max); // Make sure we always fail when a non-existent trove is passed - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _nonExistentTroveId; vm.expectRevert("!active or zombie"); - troveManager.liquidate_troves(_troveIdsToLiquidate); + liquidatorMock.liquidate(_nonExistentTroveId, type(uint256).max); } - function test_liquidateTroves_aboveMCR( + function test_liquidateTrove_aboveMCR( uint256 _amount ) public { _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); @@ -627,10 +746,8 @@ contract LiquidateTests is Base { uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); // Make sure we cannot liquidate a trove that is above MCR - uint256[MAX_LIQUIDATIONS] memory _troveIdsToLiquidate; - _troveIdsToLiquidate[0] = _troveId; vm.expectRevert("!collateral_ratio"); - troveManager.liquidate_troves(_troveIdsToLiquidate); + liquidatorMock.liquidate(_troveId, type(uint256).max); } function test_liquidateGas() public { @@ -652,15 +769,222 @@ contract LiquidateTests is Base { vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_priceDropToBelowMCR)); vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), abi.encode(_priceDropToBelowMCR18)); - uint256[MAX_LIQUIDATIONS] memory _troveIds; - _troveIds[0] = _troveId; - uint256 _gasBefore = gasleft(); - vm.prank(liquidator); - troveManager.liquidate_troves(_troveIds); + liquidate(_troveId); uint256 _gasUsed = _gasBefore - gasleft(); console2.log("Gas used to liquidate 1 trove:", _gasUsed); } + // Passing max_debt_to_repay = type(uint256).max should cap the liquidation at safe CR + // --> trove stays open with CR ≈ safe_collateral_ratio + function test_liquidateTrove_capsAtSafeCR( + uint256 _amount + ) public { + _amount = bound(_amount, troveManager.min_debt() * 4, maxFuzzAmount); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price to 1% below MCR + uint256 _price; + if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); + } else { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; + } + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + + uint256 _crBefore = (_trove.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove.debt; + assertLt(_crBefore, troveManager.minimum_collateral_ratio(), "E0"); + + // Liquidate with max_debt = type(uint256).max --> should be capped at safe CR + liquidate(_troveId); + + // Trove should still be active (partial liquidation, not full) + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.active), "E1"); + assertGt(_troveAfter.debt, 0, "E2"); + assertGt(_troveAfter.collateral, 0, "E3"); + + // CR should have improved + uint256 _crAfter = (_troveAfter.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _troveAfter.debt; + assertGt(_crAfter, _crBefore, "E4"); + + // CR should be approximately equal to safe_collateral_ratio + assertApproxEqRel(_crAfter, troveManager.safe_collateral_ratio(), 1e16, "E5"); // 1% tolerance + + // CR should be above MCR + assertGe(_crAfter, troveManager.minimum_collateral_ratio(), "E6"); + + // Trove still in sorted list + assertTrue(sortedTroves.contains(_troveId), "E7"); + + // Trove owner did not receive collateral (partial liquidation) + assertEq(collateralToken.balanceOf(userBorrower), 0, "E8"); + } + + // When max_debt_to_repay < safe CR cap, the smaller value is used + function test_liquidateTrove_maxDebtRespectedBelowSafeCRCap( + uint256 _amount + ) public { + _amount = bound(_amount, troveManager.min_debt() * 4, maxFuzzAmount); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price to 1% below MCR + uint256 _price; + if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); + } else { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; + } + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + + // Liquidate a small amount (1/4 of debt) -- less than what safe CR would allow + uint256 _smallDebt = _trove.debt / 4; + uint256 _crBefore = (_trove.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove.debt; + uint256 _expectedCollateral = calculateCollateralToDecrease(_crBefore, _smallDebt, _price, _trove.collateral); + + liquidatorMock.liquidate(_troveId, _smallDebt); + + // Trove should be active with exactly the requested debt removed + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.active), "E0"); + assertEq(_troveAfter.debt, _trove.debt - _smallDebt, "E1"); + assertEq(_troveAfter.collateral, _trove.collateral - _expectedCollateral, "E2"); + + // CR should have improved + uint256 _crAfter = (_troveAfter.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _troveAfter.debt; + assertGt(_crAfter, _crBefore, "E3"); + + // CR should be below safe CR (we didn't liquidate enough to reach it) + assertLt(_crAfter, troveManager.safe_collateral_ratio(), "E4"); + + // But still above MCR + assertGe(_crAfter, troveManager.minimum_collateral_ratio(), "E5"); + } + + // When safe CR cap + min_debt check forces full liquidation + function test_liquidateTrove_safeCRCapForcesFullViaMinDebt( + uint256 _amount + ) public { + _amount = bound(_amount, troveManager.min_debt(), troveManager.min_debt() * 13 / 10); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price to 1% below MCR + uint256 _price; + if (BORROW_TOKEN_PRECISION < COLLATERAL_TOKEN_PRECISION) { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt * ORACLE_PRICE_SCALE * 99 / (100 * _trove.collateral * BORROW_TOKEN_PRECISION); + } else { + _price = + troveManager.minimum_collateral_ratio() * _trove.debt / (100 * _trove.collateral) * ORACLE_PRICE_SCALE / BORROW_TOKEN_PRECISION * 99; + } + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + + // Liquidate with max --> safe CR cap would leave remaining debt below min_debt --> forced full + liquidate(_troveId); + + // Trove should be fully liquidated + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.liquidated), "E0"); + assertEq(_troveAfter.debt, 0, "E1"); + assertEq(_troveAfter.collateral, 0, "E2"); + + // Trove owner received remaining collateral + assertGt(collateralToken.balanceOf(userBorrower), 0, "E3"); + + // Trove removed from sorted list + assertFalse(sortedTroves.contains(_troveId), "E4"); + } + + // Bad debt scenario: CR < 100% + fee --> safe CR formula returns > total debt --> full liquidation + function test_liquidateTrove_badDebtFullLiquidation( + uint256 _amount + ) public { + _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); + + mintAndDepositIntoLender(userLender, _amount); + + uint256 _collateralNeeded = + (_amount * DEFAULT_TARGET_COLLATERAL_RATIO / BORROW_TOKEN_PRECISION) * ORACLE_PRICE_SCALE / priceOracle.get_price(); + + uint256 _troveId = mintAndOpenTrove(userBorrower, _collateralNeeded, _amount, DEFAULT_ANNUAL_INTEREST_RATE); + + ITroveManager.Trove memory _trove = troveManager.troves(_troveId); + + // Drop price dramatically to put CR below 100% (bad debt) + // Target CR ≈ 98% + uint256 _price = 98 * troveManager.one_pct() * _trove.debt * ORACLE_PRICE_SCALE / (_trove.collateral * BORROW_TOKEN_PRECISION); + vm.mockCall(address(priceOracle), abi.encodeWithSelector(IPriceOracleScaled.get_price.selector), abi.encode(_price)); + vm.mockCall( + address(priceOracle), + abi.encodeWithSelector(IPriceOracleNotScaled.get_price.selector, false), + abi.encode(_price * COLLATERAL_TOKEN_PRECISION * WAD / (ORACLE_PRICE_SCALE * BORROW_TOKEN_PRECISION)) + ); + + // Verify CR is below 100% + uint256 _cr = (_trove.collateral * _price / ORACLE_PRICE_SCALE) * BORROW_TOKEN_PRECISION / _trove.debt; + assertLt(_cr, BORROW_TOKEN_PRECISION, "E0"); + + // Liquidate --> safe CR formula returns > total debt --> min() caps at total debt --> full liquidation + liquidate(_troveId); + + // Trove should be fully liquidated + ITroveManager.Trove memory _troveAfter = troveManager.troves(_troveId); + assertEq(uint256(_troveAfter.status), uint256(ITroveManager.Status.liquidated), "E1"); + assertEq(_troveAfter.debt, 0, "E2"); + assertEq(_troveAfter.collateral, 0, "E3"); + + // Liquidator gets all collateral (bad debt means collateral_with_fee > trove.collateral) + assertEq(collateralToken.balanceOf(address(liquidatorMock)), _trove.collateral, "E4"); + + // Trove owner gets nothing (all collateral went to liquidator) + assertEq(collateralToken.balanceOf(userBorrower), 0, "E5"); + + // Trove removed from sorted list + assertFalse(sortedTroves.contains(_troveId), "E6"); + } + } diff --git a/test/OpenTrove.t.sol b/test/OpenTrove.t.sol index 0e66dd7..b9bfc24 100644 --- a/test/OpenTrove.t.sol +++ b/test/OpenTrove.t.sol @@ -1,9 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.23; -import {IPriceOracleNotScaled} from "./interfaces/IPriceOracleNotScaled.sol"; -import {IPriceOracleScaled} from "./interfaces/IPriceOracleScaled.sol"; - import "./Base.sol"; contract OpenTroveTests is Base { @@ -182,19 +179,14 @@ contract OpenTroveTests is Base { // Check starting price is set correctly (with buffer) assertApproxEqAbs( - auction.auctions(_auctionId).startingPrice, - _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.redemption_starting_price_buffer_percentage() - / COLLATERAL_TOKEN_PRECISION, + auction.auctions(_auctionId).starting_price, + _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.starting_price_buffer_percentage() / COLLATERAL_TOKEN_PRECISION, 3, "E3" ); // Check minimum price is set correctly (with buffer) - assertEq( - auction.auctions(_auctionId).minimumPrice, - priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD, - "E4" - ); + assertEq(auction.auctions(_auctionId).minimum_price, priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD, "E4"); // Take the auction takeAuction(_auctionId); @@ -314,15 +306,14 @@ contract OpenTroveTests is Base { // Check starting price is set correctly (with buffer) assertApproxEqAbs( - auction.auctions(0).startingPrice, - _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.redemption_starting_price_buffer_percentage() - / COLLATERAL_TOKEN_PRECISION, + auction.auctions(0).starting_price, + _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.starting_price_buffer_percentage() / COLLATERAL_TOKEN_PRECISION, 3, "E3" ); // Check minimum price is set correctly (with buffer) - assertEq(auction.auctions(0).minimumPrice, priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD, "E4"); + assertEq(auction.auctions(0).minimum_price, priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD, "E4"); // Take the auction takeAuction(0); @@ -452,15 +443,14 @@ contract OpenTroveTests is Base { // Check starting price is set correctly (with buffer) assertApproxEqAbs( - auction.auctions(0).startingPrice, - _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.redemption_starting_price_buffer_percentage() - / COLLATERAL_TOKEN_PRECISION, + auction.auctions(0).starting_price, + _auctionAvailable * priceOracle.get_price(false) / WAD * dutchDesk.starting_price_buffer_percentage() / COLLATERAL_TOKEN_PRECISION, 3, "E3" ); // Check minimum price is set correctly (with buffer) - assertEq(auction.auctions(0).minimumPrice, priceOracle.get_price(false) * dutchDesk.redemption_minimum_price_buffer_percentage() / WAD, "E4"); + assertEq(auction.auctions(0).minimum_price, priceOracle.get_price(false) * dutchDesk.minimum_price_buffer_percentage() / WAD, "E4"); // Take the auction takeAuction(0); diff --git a/test/TransferOwnership.t.sol b/test/TransferOwnership.t.sol index 2df2224..c460718 100644 --- a/test/TransferOwnership.t.sol +++ b/test/TransferOwnership.t.sol @@ -51,6 +51,7 @@ contract TransferOwnershipTests is Base { uint256 _amount, address _newOwner ) public { + vm.assume(_newOwner != userLender); _amount = bound(_amount, troveManager.min_debt(), maxFuzzAmount); // Start the ownership transfer process diff --git a/test/TroveManager.t.sol b/test/TroveManager.t.sol index 93073a1..02fd2f3 100644 --- a/test/TroveManager.t.sol +++ b/test/TroveManager.t.sol @@ -20,15 +20,18 @@ contract TroveManagerTests is Base { assertEq(troveManager.borrow_token_precision(), BORROW_TOKEN_PRECISION, "E7"); assertEq(troveManager.min_debt(), minimumDebt * BORROW_TOKEN_PRECISION, "E8"); assertEq(troveManager.minimum_collateral_ratio(), minimumCollateralRatio * BORROW_TOKEN_PRECISION / 100, "E9"); - assertEq(troveManager.upfront_interest_period(), upfrontInterestPeriod, "E10"); - assertEq(troveManager.interest_rate_adj_cooldown(), interestRateAdjCooldown, "E11"); - assertEq(troveManager.min_annual_interest_rate(), BORROW_TOKEN_PRECISION / 100 / 2, "E12"); // 0.5% - assertEq(troveManager.max_annual_interest_rate(), 250 * BORROW_TOKEN_PRECISION / 100, "E13"); // 250% - assertEq(troveManager.zombie_trove_id(), 0, "E14"); - assertEq(troveManager.total_debt(), 0, "E15"); - assertEq(troveManager.total_weighted_debt(), 0, "E16"); - assertEq(troveManager.last_debt_update_time(), 0, "E17"); - assertEq(troveManager.collateral_balance(), 0, "E18"); + assertEq(troveManager.max_penalty_collateral_ratio(), maxPenaltyCollateralRatio * BORROW_TOKEN_PRECISION / 100, "E10"); + assertEq(troveManager.min_liquidation_fee(), minLiquidationFee * BORROW_TOKEN_PRECISION / 100 / 100, "E11"); + assertEq(troveManager.max_liquidation_fee(), maxLiquidationFee * BORROW_TOKEN_PRECISION / 100 / 100, "E12"); + assertEq(troveManager.upfront_interest_period(), upfrontInterestPeriod, "E13"); + assertEq(troveManager.interest_rate_adj_cooldown(), interestRateAdjCooldown, "E14"); + assertEq(troveManager.min_annual_interest_rate(), BORROW_TOKEN_PRECISION / 100 / 2, "E15"); // 0.5% + assertEq(troveManager.max_annual_interest_rate(), 250 * BORROW_TOKEN_PRECISION / 100, "E16"); // 250% + assertEq(troveManager.zombie_trove_id(), 0, "E17"); + assertEq(troveManager.total_debt(), 0, "E18"); + assertEq(troveManager.total_weighted_debt(), 0, "E19"); + assertEq(troveManager.last_debt_update_time(), 0, "E20"); + assertEq(troveManager.collateral_balance(), 0, "E21"); } function test_initialize_revertsIfAlreadyInitialized() public { @@ -36,15 +39,19 @@ contract TroveManagerTests is Base { troveManager.initialize( ITroveManager.InitializeParams({ lender: address(lender), - dutchDesk: address(dutchDesk), - priceOracle: address(priceOracle), - sortedTroves: address(sortedTroves), - borrowToken: address(borrowToken), - collateralToken: address(collateralToken), - minimumDebt: minimumDebt, - minimumCollateralRatio: minimumCollateralRatio, - upfrontInterestPeriod: upfrontInterestPeriod, - interestRateAdjCooldown: interestRateAdjCooldown + dutch_desk: address(dutchDesk), + price_oracle: address(priceOracle), + sorted_troves: address(sortedTroves), + borrow_token: address(borrowToken), + collateral_token: address(collateralToken), + minimum_debt: minimumDebt, + safe_collateral_ratio: safeCollateralRatio, + minimum_collateral_ratio: minimumCollateralRatio, + max_penalty_collateral_ratio: maxPenaltyCollateralRatio, + min_liquidation_fee: minLiquidationFee, + max_liquidation_fee: maxLiquidationFee, + upfront_interest_period: upfrontInterestPeriod, + interest_rate_adj_cooldown: interestRateAdjCooldown }) ); } diff --git a/test/interfaces/IAuction.sol b/test/interfaces/IAuction.sol index caa40e5..01320c8 100644 --- a/test/interfaces/IAuction.sol +++ b/test/interfaces/IAuction.sol @@ -8,25 +8,24 @@ interface IAuction { // ============================================================================================ struct AuctionInfo { - uint256 kickTimestamp; - uint256 initialAmount; - uint256 currentAmount; - uint256 maximumAmount; - uint256 amountReceived; - uint256 startingPrice; - uint256 minimumPrice; + uint256 kick_timestamp; + uint256 initial_amount; + uint256 current_amount; + uint256 maximum_amount; + uint256 amount_received; + uint256 starting_price; + uint256 minimum_price; address receiver; - address surplusReceiver; - bool isLiquidation; + address surplus_receiver; } struct InitializeParams { address papi; - address buyToken; - address sellToken; - uint256 stepDuration; - uint256 stepDecayRate; - uint256 auctionLength; + address buy_token; + address sell_token; + uint256 step_duration; + uint256 step_decay_rate; + uint256 auction_length; } // ============================================================================================ @@ -48,7 +47,6 @@ interface IAuction { function auction_length() external view returns (uint256); // Accounting - function liquidation_auctions() external view returns (uint256); function auctions( uint256 auctionId ) external view returns (AuctionInfo memory); @@ -83,7 +81,6 @@ interface IAuction { function is_active( uint256 auction_id ) external view returns (bool); - function is_ongoing_liquidation_auction() external view returns (bool); // ============================================================================================ // Kick @@ -96,8 +93,7 @@ interface IAuction { uint256 starting_price, uint256 minimum_price, address receiver, - address surplus_receiver, - bool is_liquidation + address surplus_receiver ) external; function re_kick( diff --git a/test/interfaces/IDutchDesk.sol b/test/interfaces/IDutchDesk.sol index 9cbd1e0..1e3632d 100644 --- a/test/interfaces/IDutchDesk.sol +++ b/test/interfaces/IDutchDesk.sol @@ -8,17 +8,15 @@ interface IDutchDesk { // ============================================================================================ struct InitializeParams { - address troveManager; + address trove_manager; address lender; - address priceOracle; + address price_oracle; address auction; - address borrowToken; - address collateralToken; - uint256 redemptionMinimumPriceBufferPercentage; - uint256 redemptionStartingPriceBufferPercentage; - uint256 redemptionReKickStartingPriceBufferPercentage; - uint256 liquidationMinimumPriceBufferPercentage; - uint256 liquidationStartingPriceBufferPercentage; + address borrow_token; + address collateral_token; + uint256 minimum_price_buffer_percentage; + uint256 starting_price_buffer_percentage; + uint256 re_kick_starting_price_buffer_percentage; } // ============================================================================================ @@ -36,11 +34,9 @@ interface IDutchDesk { // Parameters function collateral_token_precision() external view returns (uint256); - function redemption_minimum_price_buffer_percentage() external view returns (uint256); - function redemption_starting_price_buffer_percentage() external view returns (uint256); - function redemption_re_kick_starting_price_buffer_percentage() external view returns (uint256); - function liquidation_minimum_price_buffer_percentage() external view returns (uint256); - function liquidation_starting_price_buffer_percentage() external view returns (uint256); + function minimum_price_buffer_percentage() external view returns (uint256); + function starting_price_buffer_percentage() external view returns (uint256); + function re_kick_starting_price_buffer_percentage() external view returns (uint256); // Accounting function nonce() external view returns (uint256); @@ -60,8 +56,7 @@ interface IDutchDesk { function kick( uint256 kick_amount, uint256 maximum_amount, - address receiver, - bool is_liquidation + address receiver ) external; function re_kick( diff --git a/test/interfaces/ITroveManager.sol b/test/interfaces/ITroveManager.sol index 61c282c..ca45782 100644 --- a/test/interfaces/ITroveManager.sol +++ b/test/interfaces/ITroveManager.sol @@ -37,15 +37,19 @@ interface ITroveManager { struct InitializeParams { address lender; - address dutchDesk; - address priceOracle; - address sortedTroves; - address borrowToken; - address collateralToken; - uint256 minimumDebt; - uint256 minimumCollateralRatio; - uint256 upfrontInterestPeriod; - uint256 interestRateAdjCooldown; + address dutch_desk; + address price_oracle; + address sorted_troves; + address borrow_token; + address collateral_token; + uint256 minimum_debt; + uint256 safe_collateral_ratio; + uint256 minimum_collateral_ratio; + uint256 max_penalty_collateral_ratio; + uint256 min_liquidation_fee; + uint256 max_liquidation_fee; + uint256 upfront_interest_period; + uint256 interest_rate_adj_cooldown; } // ============================================================================================ @@ -66,7 +70,11 @@ interface ITroveManager { function one_pct() external view returns (uint256); function borrow_token_precision() external view returns (uint256); function min_debt() external view returns (uint256); + function safe_collateral_ratio() external view returns (uint256); function minimum_collateral_ratio() external view returns (uint256); + function max_penalty_collateral_ratio() external view returns (uint256); + function min_liquidation_fee() external view returns (uint256); + function max_liquidation_fee() external view returns (uint256); function upfront_interest_period() external view returns (uint256); function interest_rate_adj_cooldown() external view returns (uint256); function min_annual_interest_rate() external view returns (uint256); @@ -182,9 +190,12 @@ interface ITroveManager { // Liquidate trove // ============================================================================================ - function liquidate_troves( - uint256[20] calldata trove_ids - ) external; + function liquidate_trove( + uint256 trove_id, + uint256 max_debt_to_repay, + address receiver, + bytes calldata data + ) external returns (uint256); // ============================================================================================ // Redeem diff --git a/test/mocks/LiquidatorMock.sol b/test/mocks/LiquidatorMock.sol new file mode 100644 index 0000000..f95d592 --- /dev/null +++ b/test/mocks/LiquidatorMock.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.23; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {ITroveManager} from "../interfaces/ITroveManager.sol"; + +import "forge-std/Test.sol"; + +contract LiquidatorMock is Test { + + ITroveManager public immutable troveManager; + IERC20 public immutable borrowToken; + + constructor( + ITroveManager _troveManager, + IERC20 _borrowToken + ) { + troveManager = _troveManager; + borrowToken = _borrowToken; + } + + function liquidate( + uint256 _troveId, + uint256 _maxAmount + ) external returns (uint256) { + return troveManager.liquidate_trove(_troveId, _maxAmount, address(this), abi.encode(uint256(420))); + } + + function takeCallback( + uint256, + address, + uint256, + uint256 _neededAmount, + bytes calldata + ) external { + deal(address(borrowToken), address(this), _neededAmount); + borrowToken.approve(msg.sender, _neededAmount); + } + +}