diff --git a/foundry.toml b/foundry.toml index 1411ebe9a..14fabfe9f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -22,11 +22,13 @@ dynamic_test_linking = true additional_compiler_profiles = [ { name = "hub", optimizer = true, via_ir = true, optimizer_runs = 22_300 }, { name = "spoke", optimizer = true, via_ir = true, optimizer_runs = 750 }, + { name = "spoke-small", optimizer = true, via_ir = true, optimizer_runs = 200 }, ] compilation_restrictions = [ { paths = "src/hub/instances/HubInstance.sol", via_ir = true, optimizer_runs = 22_300 }, { paths = "src/spoke/instances/SpokeInstance.sol", via_ir = true, optimizer_runs = 750 }, + { paths = "src/spoke/instances/BabylonSpokeInstance.sol", via_ir = true, optimizer_runs = 200 }, ] [bind_json] diff --git a/src/spoke/BabylonSpoke.sol b/src/spoke/BabylonSpoke.sol new file mode 100644 index 000000000..f5fd0685d --- /dev/null +++ b/src/spoke/BabylonSpoke.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {BabylonLiquidationLogic} from 'src/spoke/libraries/BabylonLiquidationLogic.sol'; +import {IBabylonSpoke} from 'src/spoke/interfaces/IBabylonSpoke.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {Spoke} from 'src/spoke/Spoke.sol'; + +/// @title BabylonSpoke +/// @author Aave Labs +/// @notice Spoke variant for the Babylon integration: liquidations are bounded by a collateral cap, +/// with per-reserve flags to bypass dust protection and target health factor sizing. +abstract contract BabylonSpoke is IBabylonSpoke, Spoke { + /// @dev Map of reserve identifiers to their liquidation bypass flags. + mapping(uint256 reserveId => LiquidationBypass) internal _liquidationBypass; + + /// @inheritdoc IBabylonSpoke + function updateLiquidationBypass( + uint256 reserveId, + LiquidationBypass calldata bypass + ) external restricted { + require(reserveId < _reserveCount, ReserveNotListed()); + _liquidationBypass[reserveId] = bypass; + emit UpdateLiquidationBypass(reserveId, bypass); + } + + /// @dev The canonical liquidation entry point is disabled on this Spoke: liquidations go + /// through the cap-bounded `liquidationCall` overload. + function liquidationCall( + uint256, + uint256, + address, + uint256, + bool + ) external pure override(ISpoke, Spoke) { + revert UnsupportedLiquidationCall(); + } + + /// @inheritdoc IBabylonSpoke + function liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + uint256 maxCollateralToRemove, + bool receiveShares + ) external nonReentrant { + // dust protection and target health factor sizing are bypassed if either reserve has the corresponding flag set + LiquidationBypass storage collateralBypass = _liquidationBypass[collateralReserveId]; + LiquidationBypass storage debtBypass = _liquidationBypass[debtReserveId]; + + UserAccountData memory userAccountData = _calculateUserAccountData(user); + BabylonLiquidationLogic.LiquidateUserParams memory params = BabylonLiquidationLogic + .LiquidateUserParams({ + collateralReserveId: collateralReserveId, + debtReserveId: debtReserveId, + liquidationConfig: _liquidationConfig, + oracle: ORACLE, + user: user, + debtToCover: debtToCover, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: maxCollateralToRemove, + dustThreshold: collateralBypass.bypassLiquidationDust || debtBypass.bypassLiquidationDust + ? 0 + : DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: collateralBypass.bypassTargetHealthFactor || + debtBypass.bypassTargetHealthFactor + }), + userAccountData: userAccountData, + liquidator: msg.sender, + receiveShares: receiveShares + }); + + bool isUserInDeficit = BabylonLiquidationLogic.liquidateUser({ + reserves: _reserves, + userPositions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: params + }); + + if (isUserInDeficit) { + // report deficit for all debt reserves, including the reserve being repaid + LiquidationLogic.notifyReportDeficit( + _reserves, + _userPositions, + _positionStatus, + _reserveCount, + user + ); + } else { + uint256 newRiskPremium = _calculateUserAccountData(user).riskPremium; + _notifyRiskPremiumUpdate(user, newRiskPremium); + } + } + + /// @inheritdoc IBabylonSpoke + function getLiquidationBypass( + uint256 reserveId + ) external view returns (LiquidationBypass memory) { + return _liquidationBypass[reserveId]; + } +} diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index 9dd7beab9..1707d7cc4 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -350,7 +350,7 @@ abstract contract Spoke is address user, uint256 debtToCover, bool receiveShares - ) external nonReentrant { + ) external virtual nonReentrant { UserAccountData memory userAccountData = _calculateUserAccountData(user); LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ collateralReserveId: collateralReserveId, diff --git a/src/spoke/instances/BabylonSpokeInstance.sol b/src/spoke/instances/BabylonSpokeInstance.sol new file mode 100644 index 000000000..e980cd4d5 --- /dev/null +++ b/src/spoke/instances/BabylonSpokeInstance.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity 0.8.28; + +import {BabylonSpoke} from 'src/spoke/BabylonSpoke.sol'; +import {Spoke} from 'src/spoke/Spoke.sol'; + +/// @title BabylonSpokeInstance +/// @author Aave Labs +/// @notice Implementation contract for the BabylonSpoke. +contract BabylonSpokeInstance is BabylonSpoke { + uint64 public constant SPOKE_REVISION = 1; + + /// @dev Constructor. + /// @dev During upgrade, must ensure that the new oracle is supporting existing assets on the Spoke and the replaced oracle. + /// @param oracle_ The address of the oracle. + /// @param maxUserReservesLimit_ The maximum number of collateral and borrow reserves a user can have. + constructor(address oracle_, uint16 maxUserReservesLimit_) Spoke(oracle_, maxUserReservesLimit_) { + _disableInitializers(); + } + + /// @notice Initializer. + /// @dev The authority contract must implement the `AccessManaged` interface for access control. + /// @param authority The address of the authority contract which manages permissions. + function initialize(address authority) external override reinitializer(SPOKE_REVISION) { + emit SetSpokeImmutables(ORACLE, MAX_USER_RESERVES_LIMIT); + + require(authority != address(0), InvalidAddress()); + __AccessManaged_init(authority); + if (_liquidationConfig.targetHealthFactor == 0) { + _liquidationConfig.targetHealthFactor = HEALTH_FACTOR_LIQUIDATION_THRESHOLD; + emit UpdateLiquidationConfig(_liquidationConfig); + } + } +} diff --git a/src/spoke/interfaces/IBabylonSpoke.sol b/src/spoke/interfaces/IBabylonSpoke.sol new file mode 100644 index 000000000..0ba0cfc19 --- /dev/null +++ b/src/spoke/interfaces/IBabylonSpoke.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title IBabylonSpoke +/// @author Aave Labs +/// @notice Full interface for the BabylonSpoke. +interface IBabylonSpoke is ISpoke { + /// @notice Per-reserve liquidation bypass flags. + /// @dev bypassLiquidationDust True if the liquidation dust protection is bypassed for liquidations involving the reserve. + /// @dev bypassTargetHealthFactor True if liquidations involving the reserve are not sized by the target health factor. + struct LiquidationBypass { + bool bypassLiquidationDust; + bool bypassTargetHealthFactor; + } + + /// @notice Emitted when the liquidation bypass flags of a reserve are updated. + /// @param reserveId The identifier of the reserve. + /// @param bypass The new liquidation bypass flags. + event UpdateLiquidationBypass(uint256 indexed reserveId, LiquidationBypass bypass); + + /// @notice Thrown when the disabled canonical liquidation entry point is called. + error UnsupportedLiquidationCall(); + + /// @notice Updates the liquidation bypass flags of a reserve. + /// @param reserveId The identifier of the reserve. + /// @param bypass The new liquidation bypass flags. + function updateLiquidationBypass(uint256 reserveId, LiquidationBypass calldata bypass) external; + + /// @notice Liquidates a user position with a cap on the total collateral removed. + /// @dev It reverts if the reserves associated with any of the given reserve identifiers are not listed. + /// @dev The Spoke pulls underlying repaid debt assets from caller (Liquidator), hence it needs prior approval. + /// @dev The total collateral removed is capped at `maxCollateralToRemove`; when the cap binds, the repaid + /// debt is resized to exactly consume it, and the remaining collateral and debt must respect the dust threshold. + /// @dev Dust protection and target health factor sizing are bypassed if the corresponding bypass flag is set + /// on either the collateral or the debt reserve. + /// @param collateralReserveId The reserveId of the underlying asset used as collateral by the liquidated user. + /// @param debtReserveId The reserveId of the underlying asset borrowed by the liquidated user, to be repaid by Liquidator. + /// @param user The address of the user to liquidate. + /// @param debtToCover The desired amount of debt to cover. + /// @param maxCollateralToRemove The maximum total amount of collateral to remove from the user, expressed in asset units. Use `type(uint256).max` for no cap. + /// @param receiveShares True to receive collateral in supplied shares, false to receive in underlying assets. + function liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + uint256 maxCollateralToRemove, + bool receiveShares + ) external; + + /// @notice Returns the liquidation bypass flags of a reserve. + /// @param reserveId The identifier of the reserve. + /// @return The liquidation bypass flags. + function getLiquidationBypass(uint256 reserveId) external view returns (LiquidationBypass memory); +} diff --git a/src/spoke/libraries/BabylonLiquidationLogic.sol b/src/spoke/libraries/BabylonLiquidationLogic.sol new file mode 100644 index 000000000..0acd94d11 --- /dev/null +++ b/src/spoke/libraries/BabylonLiquidationLogic.sol @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.20; + +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; +import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; +import {SpokeUtils} from 'src/spoke/libraries/SpokeUtils.sol'; +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; +import {UserPositionUtils} from 'src/spoke/libraries/UserPositionUtils.sol'; +import {ReserveFlags} from 'src/spoke/libraries/ReserveFlagsMap.sol'; +import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; +import {IAaveOracle} from 'src/spoke/interfaces/IAaveOracle.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; + +/// @title BabylonLiquidationLogic library +/// @author Aave Labs +/// @notice Implements the Babylon liquidation logic: the canonical sizing extended with a cap on +/// the total collateral removed and bypasses of dust protection and target health factor sizing. +library BabylonLiquidationLogic { + using MathUtils for *; + using PercentageMath for uint256; + using WadRayMath for uint256; + using SpokeUtils for *; + using UserPositionUtils for ISpoke.UserPosition; + using PositionStatusMap for ISpoke.PositionStatus; + + /// @notice Caller-supplied overrides to the canonical liquidation sizing. + /// @dev maxCollateralToRemove The maximum total amount of collateral to remove from the user, expressed in asset units. `type(uint256).max` for no cap. + /// @dev dustThreshold The liquidation dust threshold (in value terms). Zero disables dust protection. + /// @dev bypassTargetHealthFactor True to size the repaid debt to the full reserve debt instead of the target health factor. + struct LiquidationOverrides { + uint256 maxCollateralToRemove; + uint256 dustThreshold; + bool bypassTargetHealthFactor; + } + + struct LiquidateUserParams { + uint256 collateralReserveId; + uint256 debtReserveId; + address oracle; + address user; + ISpoke.LiquidationConfig liquidationConfig; + uint256 debtToCover; + LiquidationOverrides overrides; + ISpoke.UserAccountData userAccountData; + address liquidator; + bool receiveShares; + } + + struct ExecuteLiquidationParams { + IHubBase collateralHub; + uint256 collateralAssetId; + uint256 collateralAssetDecimals; + uint256 collateralReserveId; + ReserveFlags collateralReserveFlags; + ISpoke.DynamicReserveConfig collateralDynConfig; + IHubBase debtHub; + uint256 debtAssetId; + uint256 debtAssetDecimals; + address debtUnderlying; + uint256 debtReserveId; + ReserveFlags debtReserveFlags; + ISpoke.LiquidationConfig liquidationConfig; + address oracle; + address user; + uint256 debtToCover; + LiquidationOverrides overrides; + uint256 healthFactor; + uint256 totalDebtValueRay; + uint256 activeCollateralCount; + uint256 borrowCount; + address liquidator; + bool receiveShares; + } + + struct CalculateDebtToLiquidateParams { + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + uint256 totalDebtValueRay; + uint256 debtAssetDecimals; + uint256 debtAssetUnit; + uint256 debtAssetPrice; + uint256 debtToCover; + uint256 collateralFactor; + uint256 liquidationBonus; + uint256 healthFactor; + uint256 targetHealthFactor; + LiquidationOverrides overrides; + } + + struct CalculateLiquidationAmountsParams { + IHubBase collateralReserveHub; + uint256 collateralReserveAssetId; + uint256 suppliedShares; + uint256 collateralAssetDecimals; + uint256 collateralAssetPrice; + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + uint256 totalDebtValueRay; + uint256 debtAssetDecimals; + uint256 debtAssetPrice; + uint256 debtToCover; + LiquidationOverrides overrides; + uint256 collateralFactor; + uint256 healthFactorForMaxBonus; + uint256 liquidationBonusFactor; + uint256 maxLiquidationBonus; + uint256 targetHealthFactor; + uint256 healthFactor; + uint256 liquidationFee; + } + + /// @notice Liquidates a user position, applying the given liquidation overrides. + /// @param reserves The mapping of reserves per reserve id. + /// @param userPositions The mapping of user positions per user per reserve. + /// @param positionStatus The mapping of position status per user. + /// @param dynamicConfig The mapping of dynamic config per reserve per dynamic config key. + /// @param params The liquidate user params. + /// @return True if the liquidation results in deficit. + function liquidateUser( + mapping(uint256 reserveId => ISpoke.Reserve) storage reserves, + mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage userPositions, + mapping(address user => ISpoke.PositionStatus) storage positionStatus, + mapping(uint256 reserveId => mapping(uint32 dynamicConfigKey => ISpoke.DynamicReserveConfig)) storage dynamicConfig, + LiquidateUserParams memory params + ) external returns (bool) { + ISpoke.Reserve storage collateralReserve = reserves.get(params.collateralReserveId); + ISpoke.Reserve storage debtReserve = reserves.get(params.debtReserveId); + + ISpoke.UserPosition storage collateralUserPosition = userPositions[params.user][ + params.collateralReserveId + ]; + ISpoke.DynamicReserveConfig storage collateralDynConfig = dynamicConfig[ + params.collateralReserveId + ][collateralUserPosition.dynamicConfigKey]; + + ExecuteLiquidationParams memory executeLiquidationParams = ExecuteLiquidationParams({ + collateralHub: collateralReserve.hub, + collateralAssetId: collateralReserve.assetId, + collateralAssetDecimals: collateralReserve.decimals, + collateralReserveId: params.collateralReserveId, + collateralReserveFlags: collateralReserve.flags, + collateralDynConfig: collateralDynConfig, + debtHub: debtReserve.hub, + debtAssetId: debtReserve.assetId, + debtAssetDecimals: debtReserve.decimals, + debtUnderlying: debtReserve.underlying, + debtReserveId: params.debtReserveId, + debtReserveFlags: debtReserve.flags, + liquidationConfig: params.liquidationConfig, + oracle: params.oracle, + user: params.user, + debtToCover: params.debtToCover, + overrides: params.overrides, + healthFactor: params.userAccountData.healthFactor, + totalDebtValueRay: params.userAccountData.totalDebtValueRay, + activeCollateralCount: params.userAccountData.activeCollateralCount, + borrowCount: params.userAccountData.borrowCount, + liquidator: params.liquidator, + receiveShares: params.receiveShares + }); + + ISpoke.UserPosition storage debtUserPosition = userPositions[params.user][params.debtReserveId]; + ISpoke.UserPosition storage collateralLiquidatorPosition = userPositions[params.liquidator][ + params.collateralReserveId + ]; + ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.user]; + + return + _executeLiquidation({ + collateralUserPosition: collateralUserPosition, + debtUserPosition: debtUserPosition, + collateralLiquidatorPosition: collateralLiquidatorPosition, + userPositionStatus: userPositionStatus, + params: executeLiquidationParams + }); + } + + /// @dev Executes the liquidation. Mirrors the canonical execution, with sizing applying the + /// liquidation overrides. + /// @param collateralUserPosition User's collateral position. + /// @param debtUserPosition User's debt position. + /// @param collateralLiquidatorPosition Liquidator's collateral position. + /// @param userPositionStatus User's position status. + /// @param params The execute liquidation params. + /// @return True if the liquidation results in deficit. + function _executeLiquidation( + ISpoke.UserPosition storage collateralUserPosition, + ISpoke.UserPosition storage debtUserPosition, + ISpoke.UserPosition storage collateralLiquidatorPosition, + ISpoke.PositionStatus storage userPositionStatus, + ExecuteLiquidationParams memory params + ) internal returns (bool) { + uint256 suppliedShares = collateralUserPosition.suppliedShares; + UserPositionUtils.DebtComponents memory debtComponents = debtUserPosition.getDebtComponents( + params.debtHub, + params.debtAssetId + ); + + LiquidationLogic._validateLiquidationCall( + LiquidationLogic.ValidateLiquidationCallParams({ + user: params.user, + liquidator: params.liquidator, + collateralReserveFlags: params.collateralReserveFlags, + debtReserveFlags: params.debtReserveFlags, + suppliedShares: suppliedShares, + drawnShares: debtComponents.drawnShares, + debtToCover: params.debtToCover, + collateralFactor: params.collateralDynConfig.collateralFactor, + isUsingAsCollateral: userPositionStatus.isUsingAsCollateral(params.collateralReserveId), + healthFactor: params.healthFactor, + receiveShares: params.receiveShares + }) + ); + + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = _calculateLiquidationAmounts( + CalculateLiquidationAmountsParams({ + collateralReserveHub: params.collateralHub, + collateralReserveAssetId: params.collateralAssetId, + suppliedShares: suppliedShares, + collateralAssetDecimals: params.collateralAssetDecimals, + collateralAssetPrice: IAaveOracle(params.oracle).getReservePrice( + params.collateralReserveId + ), + drawnShares: debtComponents.drawnShares, + premiumDebtRay: debtComponents.premiumDebtRay, + drawnIndex: debtComponents.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetPrice: IAaveOracle(params.oracle).getReservePrice(params.debtReserveId), + debtToCover: params.debtToCover, + overrides: params.overrides, + collateralFactor: params.collateralDynConfig.collateralFactor, + healthFactorForMaxBonus: params.liquidationConfig.healthFactorForMaxBonus, + liquidationBonusFactor: params.liquidationConfig.liquidationBonusFactor, + maxLiquidationBonus: params.collateralDynConfig.maxLiquidationBonus, + targetHealthFactor: params.liquidationConfig.targetHealthFactor, + healthFactor: params.healthFactor, + liquidationFee: params.collateralDynConfig.liquidationFee + }) + ); + + LiquidationLogic.LiquidateCollateralResult memory liquidateCollateralResult = LiquidationLogic + ._liquidateCollateral( + collateralUserPosition, + collateralLiquidatorPosition, + LiquidationLogic.LiquidateCollateralParams({ + hub: params.collateralHub, + assetId: params.collateralAssetId, + sharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate, + sharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator, + liquidator: params.liquidator, + receiveShares: params.receiveShares + }) + ); + + LiquidationLogic.LiquidateDebtResult memory liquidateDebtResult = LiquidationLogic + ._liquidateDebt( + debtUserPosition, + userPositionStatus, + LiquidationLogic.LiquidateDebtParams({ + hub: params.debtHub, + assetId: params.debtAssetId, + underlying: params.debtUnderlying, + reserveId: params.debtReserveId, + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex: debtComponents.drawnIndex, + liquidator: params.liquidator + }) + ); + + emit ISpoke.LiquidationCall({ + collateralReserveId: params.collateralReserveId, + debtReserveId: params.debtReserveId, + user: params.user, + liquidator: params.liquidator, + receiveShares: params.receiveShares, + debtAmountRestored: liquidateDebtResult.amountRestored, + drawnSharesLiquidated: liquidationAmounts.drawnSharesToLiquidate, + premiumDelta: liquidateDebtResult.premiumDelta, + collateralAmountRemoved: liquidateCollateralResult.amountRemoved, + collateralSharesLiquidated: liquidationAmounts.collateralSharesToLiquidate, + collateralSharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator + }); + + return + LiquidationLogic._evaluateDeficit({ + isCollateralPositionEmpty: liquidateCollateralResult.isCollateralPositionEmpty, + isDebtPositionEmpty: liquidateDebtResult.isDebtPositionEmpty, + activeCollateralCount: params.activeCollateralCount, + borrowCount: params.borrowCount + }); + } + + /// @notice Calculates the liquidation amounts, applying the liquidation overrides. + /// @dev Mirrors the canonical calculation, with the collateral available for seizure bounded by + /// `min(suppliedShares, maxCollateralSharesToRemove)`. When the cap binds below the user's + /// collateral balance, the remaining collateral and debt must respect the dust threshold. + function _calculateLiquidationAmounts( + CalculateLiquidationAmountsParams memory params + ) internal view returns (LiquidationLogic.LiquidationAmounts memory) { + uint256 collateralAssetUnit = MathUtils.uncheckedExp(10, params.collateralAssetDecimals); + uint256 debtAssetUnit = MathUtils.uncheckedExp(10, params.debtAssetDecimals); + + uint256 liquidationBonus = LiquidationLogic.calculateLiquidationBonus({ + healthFactorForMaxBonus: params.healthFactorForMaxBonus, + liquidationBonusFactor: params.liquidationBonusFactor, + healthFactor: params.healthFactor, + maxLiquidationBonus: params.maxLiquidationBonus + }); + + uint256 availableCollateralShares = params.suppliedShares.min( + params.overrides.maxCollateralToRemove == type(uint256).max + ? type(uint256).max + : params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + params.overrides.maxCollateralToRemove + ) + ); + + // To prevent accumulation of dust, one of the following conditions is enforced: + // 1. liquidate all debt + // 2. liquidate all collateral + // 3. leave at least `overrides.dustThreshold` of collateral and debt (in value terms) + // The threshold is zero when dust protection is bypassed, so conditions are trivially met. + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = _calculateDebtToLiquidate( + CalculateDebtToLiquidateParams({ + drawnShares: params.drawnShares, + premiumDebtRay: params.premiumDebtRay, + drawnIndex: params.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetUnit: debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + debtToCover: params.debtToCover, + collateralFactor: params.collateralFactor, + liquidationBonus: liquidationBonus, + healthFactor: params.healthFactor, + targetHealthFactor: params.targetHealthFactor, + overrides: params.overrides + }) + ); + + uint256 collateralSharesToLiquidate = LiquidationLogic._calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams({ + collateralReserveHub: params.collateralReserveHub, + collateralReserveAssetId: params.collateralReserveAssetId, + collateralAssetUnit: collateralAssetUnit, + collateralAssetPrice: params.collateralAssetPrice, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex, + debtAssetUnit: debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + liquidationBonus: liquidationBonus + }) + ); + + bool leavesCollateralDust; + if (collateralSharesToLiquidate < params.suppliedShares) { + uint256 collateralRemaining = params.collateralReserveHub.previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares.uncheckedSub(collateralSharesToLiquidate) + ); + leavesCollateralDust = + collateralRemaining.toValue({ + decimals: params.collateralAssetDecimals, + price: params.collateralAssetPrice + }) < params.overrides.dustThreshold; + } + + // debt is fully liquidated if and only if all drawn shares are liquidated + if ( + collateralSharesToLiquidate > availableCollateralShares || + (leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares) + ) { + collateralSharesToLiquidate = availableCollateralShares; + + // - `debtRayToLiquidate` is decreased if `collateralSharesToLiquidate > availableCollateralShares` (if so, debt dust could remain). + // - `debtRayToLiquidate` is increased if `(leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares)`, + // ensuring the available collateral is fully liquidated (potentially bypassing the target health factor). + uint256 debtRayToLiquidate = Math.mulDiv( + params.collateralReserveHub.previewAddByShares( + params.collateralReserveAssetId, + collateralSharesToLiquidate + ), + params.collateralAssetPrice * + debtAssetUnit * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + params.debtAssetPrice * collateralAssetUnit * liquidationBonus, + Math.Rounding.Ceil + ); + + if (debtRayToLiquidate <= params.premiumDebtRay) { + // `premiumDebtRayToLiquidate` may exceed `debtRayToLiquidate` as a result of rounding up to asset units, ensuring full utilization of assets + premiumDebtRayToLiquidate = debtRayToLiquidate.roundRayUp().min(params.premiumDebtRay); + drawnSharesToLiquidate = 0; + } else { + premiumDebtRayToLiquidate = params.premiumDebtRay; + drawnSharesToLiquidate = (debtRayToLiquidate - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + + // `drawnSharesToLiquidate` may exceed `params.drawnShares` due to rounding. + if (drawnSharesToLiquidate > params.drawnShares) { + drawnSharesToLiquidate = params.drawnShares; + + // `collateralSharesToLiquidate` may exceed `availableCollateralShares` due to rounding. + // If this happens, simply cap `collateralSharesToLiquidate` to `availableCollateralShares` since + // debt to liquidate would be the same (it is already calculated based on `availableCollateralShares`). + collateralSharesToLiquidate = LiquidationLogic + ._calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams({ + collateralReserveHub: params.collateralReserveHub, + collateralReserveAssetId: params.collateralReserveAssetId, + collateralAssetUnit: collateralAssetUnit, + collateralAssetPrice: params.collateralAssetPrice, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex, + debtAssetUnit: debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + liquidationBonus: liquidationBonus + }) + ) + .min(availableCollateralShares); + } + } + } + + // when the cap binds below the user's collateral balance, the collateral reserve cannot be + // fully liquidated: the remaining collateral and debt must respect the dust threshold + if ( + params.overrides.dustThreshold > 0 && + availableCollateralShares < params.suppliedShares && + drawnSharesToLiquidate < params.drawnShares + ) { + _validateRemainingDust({ + params: params, + collateralSharesToLiquidate: collateralSharesToLiquidate, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate + }); + } + + // revert if the liquidator does not intend to cover the necessary debt to prevent dust from remaining + require( + params.debtToCover >= + drawnSharesToLiquidate.rayMulUp(params.drawnIndex) + premiumDebtRayToLiquidate.fromRayUp(), + ISpoke.MustNotLeaveDust() + ); + + uint256 collateralSharesToLiquidator = collateralSharesToLiquidate - + collateralSharesToLiquidate.mulDivUp( + params.liquidationFee * (liquidationBonus - PercentageMath.PERCENTAGE_FACTOR), + liquidationBonus * PercentageMath.PERCENTAGE_FACTOR + ); + + return + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate + }); + } + + /// @notice Calculates the amount of drawn shares and premium debt that should be liquidated. + /// @dev Mirrors the canonical calculation, sizing to the full reserve debt when target health + /// factor sizing is bypassed and using the given dust threshold. + /// @return The amount of drawn shares to liquidate. Does not exceed `params.drawnShares`. + /// @return The amount of premium debt to liquidate. Does not exceed `params.premiumDebtRay`. + function _calculateDebtToLiquidate( + CalculateDebtToLiquidateParams memory params + ) internal pure returns (uint256, uint256) { + // when target health factor sizing is bypassed, size to the full debt of the reserve, + // equivalent to an infinite target health factor + uint256 debtRayToTarget = params.overrides.bypassTargetHealthFactor + ? params.drawnShares * params.drawnIndex + params.premiumDebtRay + : LiquidationLogic._calculateDebtToTargetHealthFactor( + LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ + totalDebtValueRay: params.totalDebtValueRay, + debtAssetUnit: params.debtAssetUnit, + debtAssetPrice: params.debtAssetPrice, + collateralFactor: params.collateralFactor, + liquidationBonus: params.liquidationBonus, + healthFactor: params.healthFactor, + targetHealthFactor: params.targetHealthFactor + }) + ); + + // `premiumDebtRayToLiquidate` may exceed `debtRayToTarget` as a result of rounding up to asset units, ensuring full utilization of assets + uint256 premiumDebtRayToLiquidate = debtRayToTarget.roundRayUp().min(params.premiumDebtRay); + // strict inequality is mandatory given rounding + if (params.debtToCover < premiumDebtRayToLiquidate.fromRayUp()) { + premiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToLiquidate; + if ( + premiumDebtRayToLiquidate == params.premiumDebtRay && + premiumDebtRayToLiquidate < debtRayToTarget + ) { + uint256 drawnSharesToTarget = (debtRayToTarget - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - premiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + + drawnSharesToLiquidate = drawnSharesToTarget.min(drawnSharesToCover).min(params.drawnShares); + } + + uint256 debtRayRemaining = (params.drawnShares - drawnSharesToLiquidate) * params.drawnIndex + + params.premiumDebtRay - + premiumDebtRayToLiquidate; + + // debt is fully liquidated if and only if all drawn shares are liquidated (premium debt is always liquidated first) + bool leavesDebtDust = (drawnSharesToLiquidate < params.drawnShares) && + debtRayRemaining.toValue({decimals: params.debtAssetDecimals, price: params.debtAssetPrice}) < + params.overrides.dustThreshold.toRay(); + + if (leavesDebtDust) { + // target health factor is bypassed to prevent leaving dust + drawnSharesToLiquidate = params.drawnShares; + premiumDebtRayToLiquidate = params.premiumDebtRay; + } + + return (drawnSharesToLiquidate, premiumDebtRayToLiquidate); + } + + /// @dev Reverts unless the remaining collateral and debt balances both respect the dust threshold. + function _validateRemainingDust( + CalculateLiquidationAmountsParams memory params, + uint256 collateralSharesToLiquidate, + uint256 drawnSharesToLiquidate, + uint256 premiumDebtRayToLiquidate + ) internal view { + uint256 collateralValueRemaining = params + .collateralReserveHub + .previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares.uncheckedSub(collateralSharesToLiquidate) + ) + .toValue({decimals: params.collateralAssetDecimals, price: params.collateralAssetPrice}); + uint256 debtValueRayRemaining = ((params.drawnShares - drawnSharesToLiquidate) * + params.drawnIndex + + params.premiumDebtRay - + premiumDebtRayToLiquidate).toValue({ + decimals: params.debtAssetDecimals, + price: params.debtAssetPrice + }); + require( + collateralValueRemaining >= params.overrides.dustThreshold && + debtValueRayRemaining >= params.overrides.dustThreshold.toRay(), + ISpoke.MustNotLeaveDust() + ); + } +} diff --git a/tests/contracts/babylon-spoke/BabylonLiquidationLogic.LiquidationAmounts.t.sol b/tests/contracts/babylon-spoke/BabylonLiquidationLogic.LiquidationAmounts.t.sol new file mode 100644 index 000000000..8bf78fba0 --- /dev/null +++ b/tests/contracts/babylon-spoke/BabylonLiquidationLogic.LiquidationAmounts.t.sol @@ -0,0 +1,482 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/contracts/spoke/libraries/liquidation-logic/LiquidationLogic.Base.t.sol'; +import {BabylonLiquidationLogic} from 'src/spoke/libraries/BabylonLiquidationLogic.sol'; +import {BabylonLiquidationLogicWrapper} from 'tests/helpers/mocks/BabylonLiquidationLogicWrapper.sol'; + +contract BabylonLiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { + BabylonLiquidationLogicWrapper internal babylonLiquidationLogicWrapper; + + function setUp() public virtual override { + super.setUp(); + babylonLiquidationLogicWrapper = new BabylonLiquidationLogicWrapper(); + } + + function test_calculateLiquidationAmounts_neutralOverrides_matchesCanonical() public { + // with no cap, the default dust threshold and no bypass, sizing matches the canonical logic + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + LiquidationLogic.LiquidationAmounts memory canonicalAmounts = liquidationLogicWrapper + .calculateLiquidationAmounts( + LiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + LiquidationLogic.LiquidationAmounts memory babylonAmounts = babylonLiquidationLogicWrapper + .calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: type(uint256).max, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: false + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + _assertLiquidationAmountsEq(babylonAmounts, canonicalAmounts); + } + + function test_calculateLiquidationAmounts_MaxCollateralToRemove_capBinds() public { + // uncapped sizing seizes 4800 collateral shares (see the canonical EnoughCollateral test) + // cap: 3000 assets = 2400 shares, binds + // resized debt to liquidate = 3000 * $1 / 120% / $2000 = 1.25 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (1.25 - 0.5) / 1.6 = 0.46875 + // bonus collateral shares = 2400 - 2400 / 120% = 400 + // collateral fee shares = 400 * 10% = 40 + // collateral shares to liquidator = 2400 - 40 = 2360 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = babylonLiquidationLogicWrapper + .calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: 3000e6, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: false + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + _assertLiquidationAmountsEq( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 2400e6, + collateralSharesToLiquidator: 2360e6, + drawnSharesToLiquidate: 0.46875e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); + } + + function test_calculateLiquidationAmounts_MaxCollateralToRemove_capBindsWithinPremium() public { + // cap: 600 assets = 480 shares, binds + // resized debt to liquidate = 600 * $1 / 120% / $2000 = 0.25, below the 0.5 premium + // premiumDebtRayToLiquidate = 0.25 + // drawnSharesToLiquidate = 0 + // bonus collateral shares = 480 - 480 / 120% = 80 + // collateral fee shares = 80 * 10% = 8 + // collateral shares to liquidator = 480 - 8 = 472 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = babylonLiquidationLogicWrapper + .calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: 600e6, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: false + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + _assertLiquidationAmountsEq( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 480e6, + collateralSharesToLiquidator: 472e6, + drawnSharesToLiquidate: 0, + premiumDebtRayToLiquidate: 0.25e18 * 1e27 + }) + ); + } + + function test_calculateLiquidationAmounts_MaxCollateralToRemove_revertsWith_MustNotLeaveDust() + public + { + // supplied shares: 4500, cap: 5000 assets = 4000 shares, binds + // remaining collateral = 500 shares = $625, below the dust threshold while debt remains + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonLiquidationLogicWrapper.calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 4500e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: 5000e6, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: false + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + } + + function test_calculateLiquidationAmounts_BypassTargetHealthFactor() public { + // target health factor sizing is bypassed: debt to liquidate = min(3, 3 * 1.6 + 0.5) = 3 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (3 - 0.5) / 1.6 = 1.5625 + // collateral to liquidate = 3 * 120% * $2000 / $1 = 7200 + // collateral shares to liquidate = 7200 / 1.25 = 5760 + // bonus collateral shares = 5760 - 5760 / 120% = 960 + // collateral fee shares = 960 * 10% = 96 + // collateral shares to liquidator = 5760 - 96 = 5664 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = babylonLiquidationLogicWrapper + .calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 3e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: type(uint256).max, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: true + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + _assertLiquidationAmountsEq( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 5760e6, + collateralSharesToLiquidator: 5664e6, + drawnSharesToLiquidate: 1.5625e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); + } + + function test_calculateLiquidationAmounts_ZeroDustThreshold_allowsDebtDust() public { + // bypassing target health factor, debtToCover 5 leaves 0.3 units of debt ($600): + // below the default dust threshold, allowed with a zero threshold + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (5 - 0.5) / 1.6 = 2.8125 + // collateral to liquidate = 5 * 120% * $2000 / $1 = 12000 + // collateral shares to liquidate = 12000 / 1.25 = 9600 + // bonus collateral shares = 9600 - 9600 / 120% = 1600 + // collateral fee shares = 1600 * 10% = 160 + // collateral shares to liquidator = 9600 - 160 = 9440 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = babylonLiquidationLogicWrapper + .calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 5e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: type(uint256).max, + dustThreshold: 0, + bypassTargetHealthFactor: true + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + + _assertLiquidationAmountsEq( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 9600e6, + collateralSharesToLiquidator: 9440e6, + drawnSharesToLiquidate: 2.8125e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); + } + + function test_calculateLiquidationAmounts_DefaultDustThreshold_revertsOnDebtDust() public { + // same inputs as test_calculateLiquidationAmounts_ZeroDustThreshold_allowsDebtDust with the + // default dust threshold: the remaining $600 of debt forces a full repayment above debtToCover + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonLiquidationLogicWrapper.calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 5e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: type(uint256).max, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: true + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + } + + function test_calculateLiquidationAmounts_MaxCollateralToRemove_dustBumpExceedsCap_reverts() + public + { + // bypassing target health factor, debtToCover 5 leaves dust debt, bumping the repayment to + // the full 5.3 units of debt; the corresponding seizure (5.3 * 120% * $2000 / $1 = 12720 + // assets) exceeds the 12000 asset cap, so the capped repayment leaves dust debt again and + // must revert + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice({ + hub: collateralReserveHub, + assetId: collateralAssetId, + totalAddedAssets: 12_500.25e6, + addedShares: 10_000e6, + spoke: address(spoke1) + }); + + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonLiquidationLogicWrapper.calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams({ + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 15_000e6, + collateralAssetDecimals: 6, + collateralAssetPrice: 1e8, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, + debtAssetPrice: 2000e8, + debtToCover: 5e18, + overrides: BabylonLiquidationLogic.LiquidationOverrides({ + maxCollateralToRemove: 12_000e6, + dustThreshold: LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + bypassTargetHealthFactor: true + }), + collateralFactor: 50_00, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00, + maxLiquidationBonus: 120_00, + targetHealthFactor: 1e18, + healthFactor: 0.8e18, + liquidationFee: 10_00 + }) + ); + } + + function _assertLiquidationAmountsEq( + LiquidationLogic.LiquidationAmounts memory a, + LiquidationLogic.LiquidationAmounts memory b + ) internal pure { + assertEq( + a.collateralSharesToLiquidate, + b.collateralSharesToLiquidate, + 'collateralSharesToLiquidate' + ); + assertApproxEqAbs( + a.collateralSharesToLiquidator, + b.collateralSharesToLiquidator, + 1, + 'collateralSharesToLiquidator' + ); + assertEq(a.drawnSharesToLiquidate, b.drawnSharesToLiquidate, 'drawnSharesToLiquidate'); + assertEq(a.premiumDebtRayToLiquidate, b.premiumDebtRayToLiquidate, 'premiumDebtRayToLiquidate'); + } +} diff --git a/tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol b/tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol new file mode 100644 index 000000000..52848fd45 --- /dev/null +++ b/tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/contracts/spoke/liquidation/Spoke.LiquidationCall.Base.t.sol'; +import {IBabylonSpoke} from 'src/spoke/interfaces/IBabylonSpoke.sol'; +import {BabylonSpokeInstance} from 'src/spoke/instances/BabylonSpokeInstance.sol'; + +/// @dev Upgrades spoke1 to a BabylonSpokeInstance implementation, keeping its state. +abstract contract BabylonSpokeBaseTest is SpokeLiquidationCallBaseTest { + uint256 internal constant LIQUIDATION_BONUS = 124_00; + + IBabylonSpoke public babylonSpoke; + address public liquidator = makeAddr('liquidator'); + + function setUp() public virtual override { + super.setUp(); + + BabylonSpokeInstance babylonImpl = new BabylonSpokeInstance( + spoke1.ORACLE(), + spoke1.MAX_USER_RESERVES_LIMIT() + ); + vm.prank(ProxyHelper.getProxyAdmin(address(spoke1))); + ITransparentUpgradeableProxy(address(spoke1)).upgradeToAndCall(address(babylonImpl), ''); + babylonSpoke = IBabylonSpoke(address(spoke1)); + + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = IBabylonSpoke.updateLiquidationBypass.selector; + vm.prank(ADMIN); + accessManager.setTargetFunctionRole( + address(babylonSpoke), + selectors, + Roles.SPOKE_CONFIGURATOR_ROLE + ); + + _deal(spoke1, _usdxReserveId(spoke1), liquidator, 1e30); + SpokeActions.approve({ + spoke: spoke1, + reserveId: _usdxReserveId(spoke1), + owner: liquidator, + amount: UINT256_MAX + }); + + _openSupplyPosition(spoke1, _daiReserveId(spoke1), 1e30); + _openSupplyPosition(spoke1, _usdxReserveId(spoke1), 1e30); + } + + function _updateLiquidationBypass( + uint256 reserveId, + bool bypassLiquidationDust, + bool bypassTargetHealthFactor + ) internal { + vm.prank(SPOKE_ADMIN); + babylonSpoke.updateLiquidationBypass( + reserveId, + IBabylonSpoke.LiquidationBypass({ + bypassLiquidationDust: bypassLiquidationDust, + bypassTargetHealthFactor: bypassTargetHealthFactor + }) + ); + } + + /// @dev Supplies dai collateral and borrows usdx up to the desired health factor. + /// @return The user's total usdx debt. + function _setupPosition( + uint256 collateralAmount, + uint256 healthFactor + ) internal returns (uint256) { + _increaseCollateralSupply(spoke1, _daiReserveId(spoke1), collateralAmount, alice); + _borrowToBeAtHf(spoke1, alice, _usdxReserveId(spoke1), healthFactor); + return spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice); + } + + /// @dev Converts a collateral cap to the debt repaid when the cap binds (dai and usdx both $1). + function _capToDebtRepaid(uint256 maxCollateralToRemove) internal view returns (uint256) { + return + Math.mulDiv( + _convertAssetAmount( + spoke1, + _daiReserveId(spoke1), + maxCollateralToRemove, + _usdxReserveId(spoke1) + ), + PercentageMath.PERCENTAGE_FACTOR, + LIQUIDATION_BONUS, + Math.Rounding.Ceil + ); + } + + function _getCollateralValue( + ISpoke spoke, + uint256 collateralReserveId, + address user + ) internal view returns (uint256) { + return + _convertAmountToValue( + spoke, + collateralReserveId, + spoke.getUserSuppliedAssets(collateralReserveId, user) + ); + } +} diff --git a/tests/contracts/babylon-spoke/BabylonSpoke.Config.t.sol b/tests/contracts/babylon-spoke/BabylonSpoke.Config.t.sol new file mode 100644 index 000000000..db7e1beac --- /dev/null +++ b/tests/contracts/babylon-spoke/BabylonSpoke.Config.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol'; + +contract BabylonSpokeConfigTest is BabylonSpokeBaseTest { + function test_liquidationCall_revertsWith_UnsupportedLiquidationCall() public { + vm.prank(liquidator); + vm.expectRevert(IBabylonSpoke.UnsupportedLiquidationCall.selector); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + 100e6, + false + ); + } + + function test_updateLiquidationBypass() public { + uint256 reserveId = _daiReserveId(spoke1); + IBabylonSpoke.LiquidationBypass memory bypass = babylonSpoke.getLiquidationBypass(reserveId); + assertFalse(bypass.bypassLiquidationDust); + assertFalse(bypass.bypassTargetHealthFactor); + + bypass = IBabylonSpoke.LiquidationBypass({ + bypassLiquidationDust: true, + bypassTargetHealthFactor: true + }); + vm.expectEmit(address(babylonSpoke)); + emit IBabylonSpoke.UpdateLiquidationBypass(reserveId, bypass); + vm.prank(SPOKE_ADMIN); + babylonSpoke.updateLiquidationBypass(reserveId, bypass); + + IBabylonSpoke.LiquidationBypass memory stored = babylonSpoke.getLiquidationBypass(reserveId); + assertTrue(stored.bypassLiquidationDust); + assertTrue(stored.bypassTargetHealthFactor); + + bypass = IBabylonSpoke.LiquidationBypass({ + bypassLiquidationDust: false, + bypassTargetHealthFactor: false + }); + vm.prank(SPOKE_ADMIN); + babylonSpoke.updateLiquidationBypass(reserveId, bypass); + + stored = babylonSpoke.getLiquidationBypass(reserveId); + assertFalse(stored.bypassLiquidationDust); + assertFalse(stored.bypassTargetHealthFactor); + } + + function test_updateLiquidationBypass_revertsWith_AccessManagedUnauthorized() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, alice) + ); + vm.prank(alice); + babylonSpoke.updateLiquidationBypass( + _daiReserveId(spoke1), + IBabylonSpoke.LiquidationBypass({bypassLiquidationDust: true, bypassTargetHealthFactor: true}) + ); + } + + function test_updateLiquidationBypass_revertsWith_ReserveNotListed() public { + uint256 unlistedReserveId = spoke1.getReserveCount(); + vm.expectRevert(ISpoke.ReserveNotListed.selector); + vm.prank(SPOKE_ADMIN); + babylonSpoke.updateLiquidationBypass( + unlistedReserveId, + IBabylonSpoke.LiquidationBypass({bypassLiquidationDust: true, bypassTargetHealthFactor: true}) + ); + } + + /// @dev A zero cap degenerates to a zero-amount liquidation, which the Hub rejects on restore, + /// consistent with the canonical liquidation behavior. + function test_liquidationCall_zeroMaxCollateralToRemove_revertsWith_InvalidAmount() public { + _setupPosition(2100e18, 0.98e18); + + vm.prank(liquidator); + vm.expectRevert(IHub.InvalidAmount.selector); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + 100e6, + 0, + false + ); + } +} diff --git a/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.BypassTargetHealthFactor.t.sol b/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.BypassTargetHealthFactor.t.sol new file mode 100644 index 000000000..1d13c4945 --- /dev/null +++ b/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.BypassTargetHealthFactor.t.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol'; + +contract BabylonSpokeLiquidationCallBypassTargetHealthFactorTest is BabylonSpokeBaseTest { + using WadRayMath for uint256; + using PercentageMath for uint256; + using SafeCast for *; + + function setUp() public virtual override { + super.setUp(); + + vm.prank(SPOKE_ADMIN); + spoke1.updateLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: 1.02e18, + healthFactorForMaxBonus: 0.99e18, + liquidationBonusFactor: 0 + }) + ); + _updateCollateralFactorAndLiquidationBonus( + spoke1, + _daiReserveId(spoke1), + 50_00, + LIQUIDATION_BONUS.toUint32() + ); + } + + /// @dev Canonical sizing stops at the target health factor; the flag on the collateral reserve + /// lets the same call repay the full debt. + function test_bypassTargetHealthFactor_onCollateralReserve_allowsFullRepay() public { + _setupPosition(5000e18, 0.98e18); + + uint256 snapshotId = vm.snapshotState(); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + UINT256_MAX, + false + ); + // sized to the target health factor, debt remains + assertGt(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), 0); + assertApproxEqRel( + _getUserHealthFactor(spoke1, alice), + 1.02e18, + 0.001e18, + 'health factor restored to target' + ); + + vm.revertToState(snapshotId); + _updateLiquidationBypass(_daiReserveId(spoke1), false, true); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + UINT256_MAX, + false + ); + + assertEq(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), 0); + } + + /// @dev The flag set on the debt reserve alone also bypasses the target health factor sizing. + function test_bypassTargetHealthFactor_onDebtReserve_allowsFullRepay() public { + _setupPosition(5000e18, 0.98e18); + _updateLiquidationBypass(_usdxReserveId(spoke1), false, true); + + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + UINT256_MAX, + false + ); + + assertEq(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), 0); + } + + /// @dev With the flag set, a partial debtToCover above the target sizing is honored exactly. + function test_bypassTargetHealthFactor_partialDebtToCover() public { + uint256 totalDebtBefore = _setupPosition(5000e18, 0.98e18); + uint256 debtToCover = 1200e6; + + // the canonical target health factor sizing would repay less than debtToCover + uint256 debtToTarget = liquidationLogicWrapper + .calculateDebtToTargetHealthFactor( + _getCalculateDebtToTargetHealthFactorParams( + spoke1, + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice + ) + ) + .fromRayUp(); + assertLt(debtToTarget, debtToCover); + + _updateLiquidationBypass(_daiReserveId(spoke1), false, true); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + debtToCover, + UINT256_MAX, + false + ); + + uint256 debtRepaid = totalDebtBefore - spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice); + assertApproxEqAbs(debtRepaid, debtToCover, 2, 'debt repaid'); + } +} diff --git a/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.MaxCollateralToRemove.t.sol b/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.MaxCollateralToRemove.t.sol new file mode 100644 index 000000000..dc7c55ebb --- /dev/null +++ b/tests/contracts/babylon-spoke/BabylonSpoke.LiquidationCall.MaxCollateralToRemove.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import 'tests/contracts/babylon-spoke/BabylonSpoke.Base.t.sol'; + +contract BabylonSpokeLiquidationCallMaxCollateralToRemoveTest is BabylonSpokeBaseTest { + using WadRayMath for uint256; + using PercentageMath for uint256; + using SafeCast for *; + + function setUp() public virtual override { + super.setUp(); + + vm.prank(SPOKE_ADMIN); + spoke1.updateLiquidationConfig( + ISpoke.LiquidationConfig({ + targetHealthFactor: 1.1e18, + healthFactorForMaxBonus: 0.99e18, + liquidationBonusFactor: 0 + }) + ); + _updateCollateralFactorAndLiquidationBonus( + spoke1, + _daiReserveId(spoke1), + 80_00, + LIQUIDATION_BONUS.toUint32() + ); + } + + /// @dev The cap binds below the debtToCover seizure: the repayment is resized to consume the cap. + function test_maxCollateralToRemove_capBinds_resizesRepayment() public { + uint256 totalDebtBefore = _setupPosition(2100e18, 0.98e18); + uint256 suppliedBefore = spoke1.getUserSuppliedAssets(_daiReserveId(spoke1), alice); + uint256 maxCollateralToRemove = 500e18; + + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + 1200e6, + maxCollateralToRemove, + false + ); + + uint256 collateralRemoved = suppliedBefore - + spoke1.getUserSuppliedAssets(_daiReserveId(spoke1), alice); + assertApproxEqAbs(collateralRemoved, maxCollateralToRemove, 2, 'collateral removed'); + + // dai and usdx are both worth $1: repaid debt is the cap discounted by the liquidation bonus + uint256 debtRepaid = totalDebtBefore - spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice); + assertApproxEqAbs(debtRepaid, _capToDebtRepaid(maxCollateralToRemove), 2, 'debt repaid'); + assertGt(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), 0); + } + + /// @dev A cap above the debtToCover seizure has no effect on the liquidation outcome. + function test_maxCollateralToRemove_capNotBinding_noEffect() public { + _setupPosition(2100e18, 0.98e18); + + uint256 snapshotId = vm.snapshotState(); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + 300e6, + UINT256_MAX, + false + ); + uint256 uncappedSupplied = spoke1.getUserSuppliedAssets(_daiReserveId(spoke1), alice); + uint256 uncappedDebt = spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice); + + vm.revertToState(snapshotId); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + 300e6, + 2000e18, + false + ); + + assertEq(spoke1.getUserSuppliedAssets(_daiReserveId(spoke1), alice), uncappedSupplied); + assertEq(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), uncappedDebt); + } + + /// @dev The cap binds and would leave collateral dust while debt remains. + function test_maxCollateralToRemove_revertsWith_MustNotLeaveDust_collateral() public { + uint256 totalDebtBefore = _setupPosition(2100e18, 0.85e18); + uint256 maxCollateralToRemove = 1150e18; + + // seizing the cap leaves dust collateral while the remaining debt is above the threshold + assertLt( + _getCollateralValue(spoke1, _daiReserveId(spoke1), alice) - + _convertAmountToValue(spoke1, _daiReserveId(spoke1), maxCollateralToRemove), + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD + ); + assertGt( + _convertAmountToValue( + spoke1, + _usdxReserveId(spoke1), + totalDebtBefore - _capToDebtRepaid(maxCollateralToRemove) + ), + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD + ); + + vm.prank(liquidator); + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + maxCollateralToRemove, + false + ); + } + + /// @dev Same conditions as the collateral dust revert, allowed with the flag on the collateral reserve. + function test_maxCollateralToRemove_bypassLiquidationDust_onCollateralReserve() public { + _setupPosition(2100e18, 0.85e18); + _updateLiquidationBypass(_daiReserveId(spoke1), true, false); + + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + 1150e18, + false + ); + + uint256 collateralValueRemaining = _getCollateralValue(spoke1, _daiReserveId(spoke1), alice); + assertGt(collateralValueRemaining, 0); + assertLt(collateralValueRemaining, LiquidationLogic.DUST_LIQUIDATION_THRESHOLD); + assertGt(spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice), 0); + } + + /// @dev The flag set on the debt reserve alone also bypasses the dust protection. + function test_maxCollateralToRemove_bypassLiquidationDust_onDebtReserve() public { + _setupPosition(2100e18, 0.85e18); + _updateLiquidationBypass(_usdxReserveId(spoke1), true, false); + + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + 1150e18, + false + ); + + assertLt( + _getCollateralValue(spoke1, _daiReserveId(spoke1), alice), + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD + ); + } + + /// @dev The cap binds and would leave debt dust while collateral remains. + function test_maxCollateralToRemove_revertsWith_MustNotLeaveDust_debt() public { + uint256 totalDebtBefore = _setupPosition(2100e18, 0.98e18); + uint256 maxCollateralToRemove = 1000e18; + + // seizing the cap leaves dust debt while the remaining collateral is above the threshold + assertGt( + _getCollateralValue(spoke1, _daiReserveId(spoke1), alice) - + _convertAmountToValue(spoke1, _daiReserveId(spoke1), maxCollateralToRemove), + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD + ); + assertLt( + _convertAmountToValue( + spoke1, + _usdxReserveId(spoke1), + totalDebtBefore - _capToDebtRepaid(maxCollateralToRemove) + ), + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD + ); + + vm.prank(liquidator); + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + maxCollateralToRemove, + false + ); + + // allowed once the dust protection is bypassed + _updateLiquidationBypass(_usdxReserveId(spoke1), true, false); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + UINT256_MAX, + maxCollateralToRemove, + false + ); + + uint256 debtValueRemaining = _convertAmountToValue( + spoke1, + _usdxReserveId(spoke1), + spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice) + ); + assertGt(debtValueRemaining, 0); + assertLt(debtValueRemaining, LiquidationLogic.DUST_LIQUIDATION_THRESHOLD); + } + + /// @dev Without a cap, the flag lets a partial liquidation leave dust instead of forcing a full + /// collateral liquidation or reverting. + function test_bypassLiquidationDust_withoutCap_partialLiquidationStands() public { + uint256 totalDebtBefore = _setupPosition(2100e18, 0.85e18); + uint256 debtToCover = 1000e6; + + // covering the debt leaves both collateral and debt dust + vm.prank(liquidator); + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + debtToCover, + UINT256_MAX, + false + ); + + _updateLiquidationBypass(_daiReserveId(spoke1), true, false); + vm.prank(liquidator); + babylonSpoke.liquidationCall( + _daiReserveId(spoke1), + _usdxReserveId(spoke1), + alice, + debtToCover, + UINT256_MAX, + false + ); + + // the partial liquidation stands: the collateral reserve is not fully liquidated + uint256 debtRepaid = totalDebtBefore - spoke1.getUserTotalDebt(_usdxReserveId(spoke1), alice); + assertApproxEqAbs(debtRepaid, debtToCover, 2, 'debt repaid'); + uint256 collateralValueRemaining = _getCollateralValue(spoke1, _daiReserveId(spoke1), alice); + assertGt(collateralValueRemaining, 0); + assertLt(collateralValueRemaining, LiquidationLogic.DUST_LIQUIDATION_THRESHOLD); + } +} diff --git a/tests/helpers/mocks/BabylonLiquidationLogicWrapper.sol b/tests/helpers/mocks/BabylonLiquidationLogicWrapper.sol new file mode 100644 index 000000000..3b380d37c --- /dev/null +++ b/tests/helpers/mocks/BabylonLiquidationLogicWrapper.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; +import {BabylonLiquidationLogic} from 'src/spoke/libraries/BabylonLiquidationLogic.sol'; + +contract BabylonLiquidationLogicWrapper { + function calculateLiquidationAmounts( + BabylonLiquidationLogic.CalculateLiquidationAmountsParams memory params + ) public view returns (LiquidationLogic.LiquidationAmounts memory) { + return BabylonLiquidationLogic._calculateLiquidationAmounts(params); + } + + function calculateDebtToLiquidate( + BabylonLiquidationLogic.CalculateDebtToLiquidateParams memory params + ) public pure returns (uint256, uint256) { + return BabylonLiquidationLogic._calculateDebtToLiquidate(params); + } +}