diff --git a/snapshots/Hub.Operations.json b/snapshots/Hub.Operations.json index b7b4a7c29..eea2ea487 100644 --- a/snapshots/Hub.Operations.json +++ b/snapshots/Hub.Operations.json @@ -1,18 +1,18 @@ { - "add": "86660", - "add: with transfer": "107957", - "draw": "104177", - "eliminateDeficit: full": "72422", - "eliminateDeficit: partial": "82027", - "mintFeeShares": "82721", - "payFee": "70834", - "refreshPremium": "70391", - "remove: full": "75451", - "remove: partial": "80589", - "reportDeficit": "111899", - "restore: full": "76569", - "restore: full - with transfer": "169178", - "restore: partial": "85291", - "restore: partial - with transfer": "143271", - "transferShares": "69648" + "add": "86654", + "add: with transfer": "107951", + "draw": "104159", + "eliminateDeficit: full": "72416", + "eliminateDeficit: partial": "82021", + "mintFeeShares": "82703", + "payFee": "70816", + "refreshPremium": "70373", + "remove: full": "75445", + "remove: partial": "80583", + "reportDeficit": "111893", + "restore: full": "76563", + "restore: full - with transfer": "169172", + "restore: partial": "85273", + "restore: partial - with transfer": "143253", + "transferShares": "69630" } \ No newline at end of file diff --git a/snapshots/NativeTokenGateway.Operations.json b/snapshots/NativeTokenGateway.Operations.json index 687874ada..bb2d37ce8 100644 --- a/snapshots/NativeTokenGateway.Operations.json +++ b/snapshots/NativeTokenGateway.Operations.json @@ -1,8 +1,8 @@ { - "borrowNative": "227759", - "repayNative": "166193", - "supplyAsCollateralNative": "160052", - "supplyNative": "135728", - "withdrawNative: full": "125393", - "withdrawNative: partial": "136541" + "borrowNative": "228479", + "repayNative": "166471", + "supplyAsCollateralNative": "160046", + "supplyNative": "135723", + "withdrawNative: full": "125388", + "withdrawNative: partial": "136535" } \ No newline at end of file diff --git a/snapshots/SignatureGateway.Operations.json b/snapshots/SignatureGateway.Operations.json index 7d61bd1a7..7478b82f0 100644 --- a/snapshots/SignatureGateway.Operations.json +++ b/snapshots/SignatureGateway.Operations.json @@ -1,10 +1,10 @@ { - "borrowWithSig": "212949", - "repayWithSig": "186465", + "borrowWithSig": "213712", + "repayWithSig": "186743", "setSelfAsUserPositionManagerWithSig": "75385", "setUsingAsCollateralWithSig": "85349", - "supplyWithSig": "151960", - "updateUserDynamicConfigWithSig": "63086", - "updateUserRiskPremiumWithSig": "62056", - "withdrawWithSig": "130648" + "supplyWithSig": "151955", + "updateUserDynamicConfigWithSig": "63111", + "updateUserRiskPremiumWithSig": "62081", + "withdrawWithSig": "130643" } \ No newline at end of file diff --git a/snapshots/Spoke.Getters.json b/snapshots/Spoke.Getters.json index e0510a5b6..7b915fc8c 100644 --- a/snapshots/Spoke.Getters.json +++ b/snapshots/Spoke.Getters.json @@ -1,7 +1,7 @@ { - "getUserAccountData: supplies: 0, borrows: 0": "12962", - "getUserAccountData: supplies: 1, borrows: 0": "49251", - "getUserAccountData: supplies: 2, borrows: 0": "80826", - "getUserAccountData: supplies: 2, borrows: 1": "100237", - "getUserAccountData: supplies: 2, borrows: 2": "118994" + "getUserAccountData: supplies: 0, borrows: 0": "12987", + "getUserAccountData: supplies: 1, borrows: 0": "49318", + "getUserAccountData: supplies: 2, borrows: 0": "80914", + "getUserAccountData: supplies: 2, borrows: 1": "101283", + "getUserAccountData: supplies: 2, borrows: 2": "120512" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.ZeroRiskPremium.json b/snapshots/Spoke.Operations.ZeroRiskPremium.json index ef958eda7..b6ea40d61 100644 --- a/snapshots/Spoke.Operations.ZeroRiskPremium.json +++ b/snapshots/Spoke.Operations.ZeroRiskPremium.json @@ -1,33 +1,33 @@ { - "borrow: first": "189363", - "borrow: second action, same reserve": "169312", - "liquidationCall (receiveShares): full": "295052", - "liquidationCall (receiveShares): partial": "294770", - "liquidationCall: full": "304431", - "liquidationCall: partial": "304149", - "permitReserve + repay (multicall)": "164456", - "permitReserve + supply (multicall)": "146735", - "permitReserve + supply + enable collateral (multicall)": "161148", - "repay: full": "123772", - "repay: partial": "128742", + "borrow: first": "190151", + "borrow: second action, same reserve": "170100", + "liquidationCall (receiveShares): full": "302258", + "liquidationCall (receiveShares): partial": "301676", + "liquidationCall: full": "319683", + "liquidationCall: partial": "319101", + "permitReserve + repay (multicall)": "164598", + "permitReserve + supply (multicall)": "146729", + "permitReserve + supply + enable collateral (multicall)": "161142", + "repay: full": "123914", + "repay: partial": "128872", "setUserPositionManagersWithSig: disable": "47039", "setUserPositionManagersWithSig: enable": "68951", - "supply + enable collateral (multicall)": "141328", - "supply: 0 borrows, collateral disabled": "122803", - "supply: 0 borrows, collateral enabled": "105774", - "supply: second action, same reserve": "105703", - "updateUserDynamicConfig: 1 collateral": "74548", - "updateUserDynamicConfig: 2 collaterals": "89453", - "updateUserRiskPremium: 1 borrow": "94781", - "updateUserRiskPremium: 2 borrows": "103916", + "supply + enable collateral (multicall)": "141322", + "supply: 0 borrows, collateral disabled": "122797", + "supply: 0 borrows, collateral enabled": "105768", + "supply: second action, same reserve": "105697", + "updateUserDynamicConfig: 1 collateral": "74536", + "updateUserDynamicConfig: 2 collaterals": "89404", + "updateUserRiskPremium: 1 borrow": "95584", + "updateUserRiskPremium: 2 borrows": "105233", "usingAsCollateral: 0 borrows, enable": "59578", - "usingAsCollateral: 1 borrow, disable": "104896", + "usingAsCollateral: 1 borrow, disable": "105702", "usingAsCollateral: 1 borrow, enable": "42466", - "usingAsCollateral: 2 borrows, disable": "125992", + "usingAsCollateral: 2 borrows, disable": "127220", "usingAsCollateral: 2 borrows, enable": "42478", - "withdraw: 0 borrows, full": "127753", - "withdraw: 0 borrows, partial": "132439", - "withdraw: 1 borrow, partial": "158821", - "withdraw: 2 borrows, partial": "172927", - "withdraw: non collateral": "105709" + "withdraw: 0 borrows, full": "127735", + "withdraw: 0 borrows, partial": "132550", + "withdraw: 1 borrow, partial": "159621", + "withdraw: 2 borrows, partial": "174148", + "withdraw: non collateral": "105691" } \ No newline at end of file diff --git a/snapshots/Spoke.Operations.json b/snapshots/Spoke.Operations.json index fed7160ed..3725ae435 100644 --- a/snapshots/Spoke.Operations.json +++ b/snapshots/Spoke.Operations.json @@ -1,33 +1,33 @@ { - "borrow: first": "258289", - "borrow: second action, same reserve": "201238", - "liquidationCall (receiveShares): full": "327083", - "liquidationCall (receiveShares): partial": "326801", - "liquidationCall: full": "336462", - "liquidationCall: partial": "336180", - "permitReserve + repay (multicall)": "161948", - "permitReserve + supply (multicall)": "146735", - "permitReserve + supply + enable collateral (multicall)": "161148", - "repay: full": "117851", - "repay: partial": "137221", + "borrow: first": "259059", + "borrow: second action, same reserve": "202008", + "liquidationCall (receiveShares): full": "334275", + "liquidationCall (receiveShares): partial": "333693", + "liquidationCall: full": "351700", + "liquidationCall: partial": "351118", + "permitReserve + repay (multicall)": "162062", + "permitReserve + supply (multicall)": "146729", + "permitReserve + supply + enable collateral (multicall)": "161142", + "repay: full": "117993", + "repay: partial": "137351", "setUserPositionManagersWithSig: disable": "47039", "setUserPositionManagersWithSig: enable": "68951", - "supply + enable collateral (multicall)": "141328", - "supply: 0 borrows, collateral disabled": "122803", - "supply: 0 borrows, collateral enabled": "105774", - "supply: second action, same reserve": "105703", - "updateUserDynamicConfig: 1 collateral": "74548", - "updateUserDynamicConfig: 2 collaterals": "89453", - "updateUserRiskPremium: 1 borrow": "148128", - "updateUserRiskPremium: 2 borrows": "197859", + "supply + enable collateral (multicall)": "141322", + "supply: 0 borrows, collateral disabled": "122797", + "supply: 0 borrows, collateral enabled": "105768", + "supply: second action, same reserve": "105697", + "updateUserDynamicConfig: 1 collateral": "74536", + "updateUserDynamicConfig: 2 collaterals": "89404", + "updateUserRiskPremium: 1 borrow": "148916", + "updateUserRiskPremium: 2 borrows": "199144", "usingAsCollateral: 0 borrows, enable": "59578", - "usingAsCollateral: 1 borrow, disable": "158243", + "usingAsCollateral: 1 borrow, disable": "159031", "usingAsCollateral: 1 borrow, enable": "42466", - "usingAsCollateral: 2 borrows, disable": "227935", + "usingAsCollateral: 2 borrows, disable": "229127", "usingAsCollateral: 2 borrows, enable": "42478", - "withdraw: 0 borrows, full": "127753", - "withdraw: 0 borrows, partial": "132439", - "withdraw: 1 borrow, partial": "209666", - "withdraw: 2 borrows, partial": "255403", - "withdraw: non collateral": "105709" + "withdraw: 0 borrows, full": "127735", + "withdraw: 0 borrows, partial": "132550", + "withdraw: 1 borrow, partial": "210448", + "withdraw: 2 borrows, partial": "256590", + "withdraw: non collateral": "105691" } \ No newline at end of file diff --git a/src/hub/Hub.sol b/src/hub/Hub.sol index c77d1a76c..f94285371 100644 --- a/src/hub/Hub.sol +++ b/src/hub/Hub.sol @@ -11,7 +11,7 @@ import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; import {AssetLogic} from 'src/hub/libraries/AssetLogic.sol'; import {SharesMath} from 'src/hub/libraries/SharesMath.sol'; -import {Premium} from 'src/hub/libraries/Premium.sol'; +import {IOU} from 'src/hub/libraries/IOU.sol'; import {IBasicInterestRateStrategy} from 'src/hub/interfaces/IBasicInterestRateStrategy.sol'; import {IHubBase, IHub} from 'src/hub/interfaces/IHub.sol'; @@ -306,15 +306,23 @@ contract Hub is IHub, AccessManaged { spoke.drawnShares -= drawnShares; _applyPremiumDelta(asset, spoke, premiumDelta); - uint256 premiumAmount = premiumDelta.restoredPremiumRay.fromRayUp(); - uint256 liquidity = asset.liquidity + drawnAmount + premiumAmount; + uint256 restoredRay = IOU.calculateDrawnRay(drawnShares, asset.drawnIndex) + + premiumDelta.restoredPremiumRay; + uint256 liquidity = asset.liquidity + restoredRay.fromRayUp(); uint256 balance = IERC20(asset.underlying).balanceOf(address(this)); require(balance >= liquidity, InsufficientTransferred(liquidity.uncheckedSub(balance))); asset.liquidity = liquidity.toUint120(); asset.updateDrawnRate(assetId); - emit Restore(assetId, msg.sender, drawnShares, premiumDelta, drawnAmount, premiumAmount); + emit Restore( + assetId, + msg.sender, + drawnShares, + premiumDelta, + drawnAmount, + premiumDelta.restoredPremiumRay.fromRayUp() + ); return drawnShares; } @@ -336,7 +344,7 @@ contract Hub is IHub, AccessManaged { spoke.drawnShares -= drawnShares; _applyPremiumDelta(asset, spoke, premiumDelta); - uint256 deficitAmountRay = uint256(drawnShares) * asset.drawnIndex + + uint256 deficitAmountRay = IOU.calculateDrawnRay(drawnShares, asset.drawnIndex) + premiumDelta.restoredPremiumRay; asset.deficitRay += deficitAmountRay.toUint200(); spoke.deficitRay += deficitAmountRay.toUint200(); @@ -541,20 +549,32 @@ contract Hub is IHub, AccessManaged { function getAssetOwed(uint256 assetId) external view returns (uint256, uint256) { Asset storage asset = _assets[assetId]; uint256 drawnIndex = asset.getDrawnIndex(); - return (asset.drawn(drawnIndex), asset.premium(drawnIndex)); + uint256 premium = IOU + .calculatePremiumRay(asset.premiumShares, asset.premiumOffsetRay, drawnIndex) + .fromRayUp(); + return (asset.getAssetDrawn(drawnIndex), premium); } /// @inheritdoc IHubBase function getAssetTotalOwed(uint256 assetId) external view returns (uint256) { Asset storage asset = _assets[assetId]; - return asset.totalOwed(asset.getDrawnIndex()); + return + IOU + .calculateTotalOwedRay({ + drawnShares: asset.drawnShares, + premiumShares: asset.premiumShares, + premiumOffsetRay: asset.premiumOffsetRay, + deficitRay: asset.deficitRay, + drawnIndex: asset.getDrawnIndex() + }) + .fromRayUp(); } /// @inheritdoc IHubBase function getAssetPremiumRay(uint256 assetId) external view returns (uint256) { Asset storage asset = _assets[assetId]; return - Premium.calculatePremiumRay({ + IOU.calculatePremiumRay({ premiumShares: asset.premiumShares, premiumOffsetRay: asset.premiumOffsetRay, drawnIndex: asset.getDrawnIndex() @@ -634,14 +654,28 @@ contract Hub is IHub, AccessManaged { function getSpokeOwed(uint256 assetId, address spoke) external view returns (uint256, uint256) { Asset storage asset = _assets[assetId]; SpokeData storage spokeData = _spokes[assetId][spoke]; - return (_getSpokeDrawn(asset, spokeData), _getSpokePremium(asset, spokeData)); + uint256 drawnIndex = asset.getDrawnIndex(); + uint256 drawn = IOU.calculateDrawnRay(spokeData.drawnShares, drawnIndex).fromRayUp(); + uint256 premium = IOU + .calculatePremiumRay(spokeData.premiumShares, spokeData.premiumOffsetRay, drawnIndex) + .fromRayUp(); + return (drawn, premium); } /// @inheritdoc IHubBase function getSpokeTotalOwed(uint256 assetId, address spoke) external view returns (uint256) { Asset storage asset = _assets[assetId]; SpokeData storage spokeData = _spokes[assetId][spoke]; - return _getSpokeDrawn(asset, spokeData) + _getSpokePremium(asset, spokeData); + return + IOU + .calculateTotalOwedRay({ + drawnShares: spokeData.drawnShares, + premiumShares: spokeData.premiumShares, + premiumOffsetRay: spokeData.premiumOffsetRay, + deficitRay: spokeData.deficitRay, + drawnIndex: asset.getDrawnIndex() + }) + .fromRayUp(); } /// @inheritdoc IHubBase @@ -798,20 +832,11 @@ contract Hub is IHub, AccessManaged { return shares; } - /// @dev Returns the spoke's drawn amount for a specified asset. function _getSpokeDrawn( Asset storage asset, SpokeData storage spoke ) internal view returns (uint256) { - return asset.toDrawnAssetsUp(spoke.drawnShares); - } - - /// @dev Returns the spoke's premium amount for a specified asset. - function _getSpokePremium( - Asset storage asset, - SpokeData storage spoke - ) internal view returns (uint256) { - return _getSpokePremiumRay(asset, spoke).fromRayUp(); + return IOU.calculateDrawnRay(spoke.drawnShares, asset.getDrawnIndex()).fromRayUp(); } /// @dev Returns the spoke's premium amount with full precision for a specified asset. @@ -820,7 +845,7 @@ contract Hub is IHub, AccessManaged { SpokeData storage spoke ) internal view returns (uint256) { return - Premium.calculatePremiumRay({ + IOU.calculatePremiumRay({ premiumShares: spoke.premiumShares, premiumOffsetRay: spoke.premiumOffsetRay, drawnIndex: asset.getDrawnIndex() @@ -864,11 +889,18 @@ contract Hub is IHub, AccessManaged { require(spoke.active, SpokeNotActive()); require(!spoke.halted, SpokeHalted()); uint256 drawCap = spoke.drawCap; - uint256 owed = _getSpokeDrawn(asset, spoke) + _getSpokePremium(asset, spoke); + uint256 owed = IOU + .calculateTotalOwedRay({ + drawnShares: spoke.drawnShares, + premiumShares: spoke.premiumShares, + premiumOffsetRay: spoke.premiumOffsetRay, + deficitRay: spoke.deficitRay, + drawnIndex: asset.getDrawnIndex() + }) + .fromRayUp(); require( drawCap == MAX_ALLOWED_SPOKE_CAP || - drawCap * MathUtils.uncheckedExp(10, asset.decimals) >= - owed + amount + uint256(spoke.deficitRay).fromRayUp(), + drawCap * MathUtils.uncheckedExp(10, asset.decimals) >= owed + amount, DrawCapExceeded(drawCap) ); } @@ -952,7 +984,7 @@ contract Hub is IHub, AccessManaged { int256 premiumOffsetRay, PremiumDelta calldata premiumDelta ) internal pure returns (uint120, int200) { - uint256 premiumRayBefore = Premium.calculatePremiumRay({ + uint256 premiumRayBefore = IOU.calculatePremiumRay({ premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, drawnIndex: drawnIndex @@ -961,7 +993,7 @@ contract Hub is IHub, AccessManaged { uint256 newPremiumShares = premiumShares.add(premiumDelta.sharesDelta); int256 newPremiumOffsetRay = premiumOffsetRay + premiumDelta.offsetRayDelta; - uint256 premiumRayAfter = Premium.calculatePremiumRay({ + uint256 premiumRayAfter = IOU.calculatePremiumRay({ premiumShares: newPremiumShares, premiumOffsetRay: newPremiumOffsetRay, drawnIndex: drawnIndex diff --git a/src/hub/libraries/AssetLogic.sol b/src/hub/libraries/AssetLogic.sol index 2b4f44cb4..0a08b0a04 100644 --- a/src/hub/libraries/AssetLogic.sol +++ b/src/hub/libraries/AssetLogic.sol @@ -7,7 +7,7 @@ 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 {SharesMath} from 'src/hub/libraries/SharesMath.sol'; -import {Premium} from 'src/hub/libraries/Premium.sol'; +import {IOU} from 'src/hub/libraries/IOU.sol'; import {IBasicInterestRateStrategy} from 'src/hub/interfaces/IBasicInterestRateStrategy.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; @@ -54,33 +54,18 @@ library AssetLogic { return assets.rayDivDown(asset.getDrawnIndex()); } - /// @notice Returns the total drawn assets amount for the specified asset. - function drawn(IHub.Asset storage asset, uint256 drawnIndex) internal view returns (uint256) { - return asset.drawnShares.rayMulUp(drawnIndex); - } - - /// @notice Returns the total premium amount for the specified asset. - function premium(IHub.Asset storage asset, uint256 drawnIndex) internal view returns (uint256) { - return - Premium - .calculatePremiumRay({ - premiumShares: asset.premiumShares, - drawnIndex: drawnIndex, - premiumOffsetRay: asset.premiumOffsetRay - }) - .fromRayUp(); - } - - /// @notice Returns the total amount owed for the specified asset, including drawn and premium. - function totalOwed(IHub.Asset storage asset, uint256 drawnIndex) internal view returns (uint256) { - return asset.drawn(drawnIndex) + asset.premium(drawnIndex); + function getAssetDrawn( + IHub.Asset storage asset, + uint256 drawnIndex + ) internal view returns (uint256) { + return IOU.calculateDrawnRay(asset.drawnShares, drawnIndex).fromRayUp(); } /// @notice Returns the total added assets for the specified asset. function totalAddedAssets(IHub.Asset storage asset) internal view returns (uint256) { uint256 drawnIndex = asset.getDrawnIndex(); - uint256 aggregatedOwedRay = _calculateAggregatedOwedRay({ + uint256 totalOwedRay = IOU.calculateTotalOwedRay({ drawnShares: asset.drawnShares, premiumShares: asset.premiumShares, premiumOffsetRay: asset.premiumOffsetRay, @@ -91,7 +76,7 @@ library AssetLogic { return asset.liquidity + asset.swept + - aggregatedOwedRay.fromRayUp() - + totalOwedRay.fromRayUp() - asset.realizedFees - asset.getUnrealizedFees(drawnIndex); } @@ -129,7 +114,7 @@ library AssetLogic { } /// @notice Updates the drawn rate of a specified asset. - /// @dev Premium debt is not used in the interest rate calculation. + /// @dev Premium owed is not used in the interest rate calculation. /// @dev Uses last stored index; asset accrual should have already occurred. /// @dev Imprecision from downscaling `deficitRay` does not accumulate. function updateDrawnRate(IHub.Asset storage asset, uint256 assetId) internal { @@ -137,7 +122,7 @@ library AssetLogic { uint256 newDrawnRate = IBasicInterestRateStrategy(asset.irStrategy).calculateInterestRate({ assetId: assetId, liquidity: asset.liquidity, - drawn: asset.drawn(drawnIndex), + drawn: asset.getAssetDrawn(drawnIndex), deficit: asset.deficitRay.fromRayUp(), swept: asset.swept }); @@ -194,7 +179,7 @@ library AssetLogic { int256 premiumOffsetRay = asset.premiumOffsetRay; uint256 deficitRay = asset.deficitRay; - uint256 aggregatedOwedRayAfter = _calculateAggregatedOwedRay({ + uint256 totalOwedRayAfter = IOU.calculateTotalOwedRay({ drawnShares: drawnShares, premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, @@ -202,7 +187,7 @@ library AssetLogic { drawnIndex: drawnIndex }); - uint256 aggregatedOwedRayBefore = _calculateAggregatedOwedRay({ + uint256 totalOwedRayBefore = IOU.calculateTotalOwedRay({ drawnShares: drawnShares, premiumShares: premiumShares, premiumOffsetRay: premiumOffsetRay, @@ -211,24 +196,6 @@ library AssetLogic { }); return - (aggregatedOwedRayAfter.fromRayUp() - aggregatedOwedRayBefore.fromRayUp()).percentMulDown( - liquidityFee - ); - } - - /// @notice Calculates the aggregated owed amount for a specified asset, expressed in asset units and scaled by RAY. - function _calculateAggregatedOwedRay( - uint256 drawnShares, - uint256 premiumShares, - int256 premiumOffsetRay, - uint256 deficitRay, - uint256 drawnIndex - ) internal pure returns (uint256) { - uint256 premiumRay = Premium.calculatePremiumRay({ - premiumShares: premiumShares, - premiumOffsetRay: premiumOffsetRay, - drawnIndex: drawnIndex - }); - return (drawnShares * drawnIndex) + premiumRay + deficitRay; + (totalOwedRayAfter.fromRayUp() - totalOwedRayBefore.fromRayUp()).percentMulDown(liquidityFee); } } diff --git a/src/hub/libraries/IOU.sol b/src/hub/libraries/IOU.sol new file mode 100644 index 000000000..01d427678 --- /dev/null +++ b/src/hub/libraries/IOU.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.20; + +import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; + +/// @title IOU library +/// @author Aave Labs +/// @notice Implements the IOU calculations. +library IOU { + using SafeCast for *; + + /// @notice Calculates the total owed amount with full precision. + /// @param drawnShares The number of drawn shares. + /// @param premiumShares The number of premium shares. + /// @param premiumOffsetRay The premium offset, expressed in asset units and scaled by RAY. + /// @param deficitRay The deficit amount, expressed in asset units and scaled by RAY. + /// @param drawnIndex The current drawn index. + /// @return The aggregated owed amount, expressed in asset units and scaled by RAY. + function calculateTotalOwedRay( + uint256 drawnShares, + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 deficitRay, + uint256 drawnIndex + ) internal pure returns (uint256) { + uint256 premiumRay = calculatePremiumRay({ + premiumShares: premiumShares, + premiumOffsetRay: premiumOffsetRay, + drawnIndex: drawnIndex + }); + return calculateDrawnRay(drawnShares, drawnIndex) + premiumRay + deficitRay; + } + + /// @notice Calculate the drawn amount with full precision. + /// @param drawnShares The number of drawn shares. + /// @param drawnIndex The current drawn index. + /// @return The drawn amount, expressed in asset units and scaled by RAY. + function calculateDrawnRay( + uint256 drawnShares, + uint256 drawnIndex + ) internal pure returns (uint256) { + return drawnShares * drawnIndex; + } + + /// @notice Calculates the premium debt with full precision. + /// @param premiumShares The number of premium shares. + /// @param premiumOffsetRay The premium offset, expressed in asset units and scaled by RAY. + /// @param drawnIndex The current drawn index. + /// @return The premium debt, expressed in asset units and scaled by RAY. + function calculatePremiumRay( + uint256 premiumShares, + int256 premiumOffsetRay, + uint256 drawnIndex + ) internal pure returns (uint256) { + return ((premiumShares * drawnIndex).toInt256() - premiumOffsetRay).toUint256(); + } +} diff --git a/src/hub/libraries/Premium.sol b/src/hub/libraries/Premium.sol deleted file mode 100644 index 126fcf3fe..000000000 --- a/src/hub/libraries/Premium.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs -pragma solidity ^0.8.20; - -import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; - -/// @title Premium library -/// @author Aave Labs -/// @notice Implements the premium calculations. -library Premium { - using SafeCast for *; - - /// @notice Calculates the premium debt with full precision. - /// @param premiumShares The number of premium shares. - /// @param premiumOffsetRay The premium offset, expressed in asset units and scaled by RAY. - /// @param drawnIndex The current drawn index. - /// @return The premium debt, expressed in asset units and scaled by RAY. - function calculatePremiumRay( - uint256 premiumShares, - int256 premiumOffsetRay, - uint256 drawnIndex - ) internal pure returns (uint256) { - return ((premiumShares * drawnIndex).toInt256() - premiumOffsetRay).toUint256(); - } -} diff --git a/src/libraries/math/MathUtils.sol b/src/libraries/math/MathUtils.sol index 28f0181b4..be86c3107 100644 --- a/src/libraries/math/MathUtils.sol +++ b/src/libraries/math/MathUtils.sol @@ -74,6 +74,18 @@ library MathUtils { } } + /// @notice Divides `a` by `b`, rounding up. + /// @dev Reverts if division by zero. + /// @return c = ceil(a / b). + function divUp(uint256 a, uint256 b) internal pure returns (uint256 c) { + assembly ('memory-safe') { + if iszero(b) { + revert(0, 0) + } + c := add(div(a, b), gt(mod(a, b), 0)) + } + } + /// @notice Multiplies `a` and `b` in 256 bits and divides the result by `c`, rounding down. /// @dev Reverts if division by zero or overflow occurs on intermediate multiplication. /// @return d = floor(a * b / c). diff --git a/src/libraries/math/WadRayMath.sol b/src/libraries/math/WadRayMath.sol index 3ffaf8299..8a73a9f50 100644 --- a/src/libraries/math/WadRayMath.sol +++ b/src/libraries/math/WadRayMath.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.20; /// @author Aave Labs /// @notice Provides utility functions to work with WAD and RAY units with explicit rounding. library WadRayMath { + uint256 internal constant WAD_DECIMALS = 18; uint256 internal constant WAD = 1e18; uint256 internal constant RAY = 1e27; uint256 internal constant PERCENTAGE_FACTOR = 1e4; @@ -162,6 +163,14 @@ library WadRayMath { } } + /// @notice Removes RAY precision from a given value, rounding down. + /// @return b = a / RAY. + function fromRayDown(uint256 a) internal pure returns (uint256 b) { + assembly ('memory-safe') { + b := div(a, RAY) + } + } + /// @notice Removes RAY precision from a given value, rounding up. /// @return b = ceil(a / RAY). function fromRayUp(uint256 a) internal pure returns (uint256 b) { @@ -171,35 +180,31 @@ library WadRayMath { } } - /// @notice Converts value from basis points to WAD, rounding down. - /// @dev Reverts if intermediate multiplication overflows. - /// @return b = floor(a * WAD / PERCENTAGE_FACTOR) in WAD units. + /// @notice Converts value from basis points to WAD. + /// @dev Reverts if result overflows. + /// @return b = a * (WAD / PERCENTAGE_FACTOR), expressed in WAD units. function bpsToWad(uint256 a) internal pure returns (uint256 b) { assembly ('memory-safe') { - b := mul(a, WAD) - - // to avoid overflow, b/WAD == a - if iszero(eq(div(b, WAD), a)) { + let factor := div(WAD, PERCENTAGE_FACTOR) + b := mul(a, factor) + // to avoid overflow, b/factor == a + if iszero(eq(div(b, factor), a)) { revert(0, 0) } - - b := div(b, PERCENTAGE_FACTOR) } } - /// @notice Converts value from basis points to RAY, rounding down. - /// @dev Reverts if intermediate multiplication overflows. - /// @return b = a * RAY / PERCENTAGE_FACTOR in RAY units. + /// @notice Converts value from basis points to RAY. + /// @dev Reverts if result overflows. + /// @return b = a * (RAY / PERCENTAGE_FACTOR), expressed in RAY units. function bpsToRay(uint256 a) internal pure returns (uint256 b) { assembly ('memory-safe') { - b := mul(a, RAY) - - // to avoid overflow, b/RAY == a - if iszero(eq(div(b, RAY), a)) { + let factor := div(RAY, PERCENTAGE_FACTOR) + b := mul(a, factor) + // to avoid overflow, b/factor == a + if iszero(eq(div(b, factor), a)) { revert(0, 0) } - - b := div(b, PERCENTAGE_FACTOR) } } } diff --git a/src/spoke/Spoke.sol b/src/spoke/Spoke.sol index c4c5d7f1c..c6ce43f0d 100644 --- a/src/spoke/Spoke.sol +++ b/src/spoke/Spoke.sol @@ -4,12 +4,14 @@ pragma solidity 0.8.28; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; import {ReentrancyGuardTransient} from 'src/dependencies/openzeppelin/ReentrancyGuardTransient.sol'; import {AccessManagedUpgradeable} from 'src/dependencies/openzeppelin-upgradeable/AccessManagedUpgradeable.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 {IOU} from 'src/hub/libraries/IOU.sol'; import {EIP712Hash} from 'src/spoke/libraries/EIP712Hash.sol'; import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; @@ -318,35 +320,42 @@ abstract contract Spoke is _validateRepay(reserve.flags); uint256 drawnIndex = reserve.hub.getAssetDrawnIndex(reserve.assetId); - (uint256 drawnDebtRestored, uint256 premiumDebtRayRestored) = userPosition + (uint256 drawnSharesToRestore, uint256 premiumDebtRayToRestore) = userPosition .calculateRestoreAmount(drawnIndex, amount); - uint256 restoredShares = drawnDebtRestored.rayDivDown(drawnIndex); IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ - drawnSharesTaken: restoredShares, + drawnSharesTaken: drawnSharesToRestore.toUint120(), drawnIndex: drawnIndex, riskPremium: _positionStatus[onBehalfOf].riskPremium, - restoredPremiumRay: premiumDebtRayRestored + restoredPremiumRay: premiumDebtRayToRestore }); - uint256 totalDebtRestored = drawnDebtRestored + premiumDebtRayRestored.fromRayUp(); + uint256 totalDebtRestored = (IOU.calculateDrawnRay(drawnSharesToRestore, drawnIndex) + + premiumDebtRayToRestore).fromRayUp(); IERC20(reserve.underlying).safeTransferFrom( msg.sender, address(reserve.hub), totalDebtRestored ); - reserve.hub.restore(reserve.assetId, drawnDebtRestored, premiumDelta); + reserve.hub.restore(reserve.assetId, drawnSharesToRestore.rayMulUp(drawnIndex), premiumDelta); userPosition.applyPremiumDelta(premiumDelta); - userPosition.drawnShares -= restoredShares.toUint120(); + userPosition.drawnShares -= drawnSharesToRestore.toUint120(); if (userPosition.drawnShares == 0) { PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; positionStatus.setBorrowing(reserveId, false); } - emit Repay(reserveId, msg.sender, onBehalfOf, restoredShares, totalDebtRestored, premiumDelta); + emit Repay( + reserveId, + msg.sender, + onBehalfOf, + drawnSharesToRestore, + totalDebtRestored, + premiumDelta + ); - return (restoredShares, totalDebtRestored); + return (drawnSharesToRestore, totalDebtRestored); } /// @inheritdoc ISpokeBase @@ -357,44 +366,31 @@ abstract contract Spoke is uint256 debtToCover, bool receiveShares ) external nonReentrant { - Reserve storage collateralReserve = _getReserve(collateralReserveId); - Reserve storage debtReserve = _getReserve(debtReserveId); - DynamicReserveConfig storage collateralDynConfig = _dynamicConfig[collateralReserveId][ - _userPositions[user][collateralReserveId].dynamicConfigKey - ]; UserAccountData memory userAccountData = _calculateUserAccountData(user); - uint256 drawnIndex = debtReserve.hub.getAssetDrawnIndex(debtReserve.assetId); - (uint256 drawnDebt, uint256 premiumDebtRay) = _userPositions[user][debtReserveId].getDebt( - drawnIndex - ); - LiquidationLogic.LiquidateUserParams memory params = LiquidationLogic.LiquidateUserParams({ collateralReserveId: collateralReserveId, debtReserveId: debtReserveId, + liquidationConfig: _liquidationConfig, oracle: ORACLE, user: user, debtToCover: debtToCover, healthFactor: userAccountData.healthFactor, - drawnDebt: drawnDebt, - premiumDebtRay: premiumDebtRay, - drawnIndex: drawnIndex, - totalDebtValue: userAccountData.totalDebtValue, + totalDebtValueRay: userAccountData.totalDebtValueRay, activeCollateralCount: userAccountData.activeCollateralCount, borrowedCount: userAccountData.borrowedCount, liquidator: msg.sender, receiveShares: receiveShares }); - bool isUserInDeficit = LiquidationLogic.liquidateUser( - collateralReserve, - debtReserve, - _userPositions, - _positionStatus, - _liquidationConfig, - collateralDynConfig, - params - ); + bool isUserInDeficit = LiquidationLogic.liquidateUser({ + collateralReserve: _getReserve(collateralReserveId), + debtReserve: _getReserve(debtReserveId), + positions: _userPositions, + positionStatus: _positionStatus, + dynamicConfig: _dynamicConfig, + params: params + }); uint256 newRiskPremium = 0; if (isUserInDeficit) { @@ -600,30 +596,36 @@ abstract contract Spoke is function getUserDebt(uint256 reserveId, address user) external view returns (uint256, uint256) { Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; - (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay( reserve.hub, reserve.assetId ); - return (drawnDebt, premiumDebtRay.fromRayUp()); + return (drawnDebtRay.fromRayUp(), premiumDebtRay.fromRayUp()); } /// @inheritdoc ISpokeBase function getUserTotalDebt(uint256 reserveId, address user) external view returns (uint256) { Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; - (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay( reserve.hub, reserve.assetId ); - return (drawnDebt + premiumDebtRay.fromRayUp()); + return (drawnDebtRay + premiumDebtRay).fromRayUp(); } /// @inheritdoc ISpokeBase - function getUserPremiumDebtRay(uint256 reserveId, address user) external view returns (uint256) { + function getUserDebtRay( + uint256 reserveId, + address user + ) external view returns (uint256, uint256) { Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[user][reserveId]; - (, uint256 premiumDebtRay) = userPosition.getDebt(reserve.hub, reserve.assetId); - return premiumDebtRay; + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay( + reserve.hub, + reserve.assetId + ); + return (drawnDebtRay, premiumDebtRay); } /// @inheritdoc ISpoke @@ -732,7 +734,7 @@ abstract contract Spoke is Reserve storage reserve = _reserves[reserveId]; uint256 assetPrice = IAaveOracle(ORACLE).getReservePrice(reserveId); - uint256 assetUnit = MathUtils.uncheckedExp(10, reserve.decimals); + uint256 assetDecimals = reserve.decimals; if (collateral) { uint256 collateralFactor = _dynamicConfig[reserveId][ @@ -744,10 +746,10 @@ abstract contract Spoke is uint256 suppliedShares = userPosition.suppliedShares; if (suppliedShares > 0) { // cannot round down to zero - uint256 userCollateralValue = (reserve.hub.previewRemoveByShares( - reserve.assetId, - suppliedShares - ) * assetPrice).wadDivDown(assetUnit); + uint256 userCollateralValue = reserve + .hub + .previewRemoveByShares(reserve.assetId, suppliedShares) + .toValue({decimals: assetDecimals, price: assetPrice}); accountData.totalCollateralValue += userCollateralValue; collateralInfo.add( accountData.activeCollateralCount, @@ -761,41 +763,43 @@ abstract contract Spoke is } if (borrowing) { - (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt( + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay( reserve.hub, reserve.assetId ); - // we can simplify since there is no precision loss due to the division here - accountData.totalDebtValue += ((drawnDebt + premiumDebtRay.fromRayUp()) * assetPrice) - .wadDivUp(assetUnit); + accountData.totalDebtValueRay += ( + (drawnDebtRay + premiumDebtRay).toValue({decimals: assetDecimals, price: assetPrice}) + ); accountData.borrowedCount = accountData.borrowedCount.uncheckedAdd(1); } } - if (accountData.totalDebtValue > 0) { - // at this point, `avgCollateralFactor` is the collateral-weighted sum (scaled by `collateralFactor` in BPS) - // health factor uses this directly for simplicity - // the division by `totalCollateralValue` to compute the weighted average is done later - accountData.healthFactor = accountData - .avgCollateralFactor - .wadDivDown(accountData.totalDebtValue) - .fromBpsDown(); + if (accountData.totalDebtValueRay > 0) { + // at this point, `avgCollateralFactor` is the total collateral value weighted by collateral factors, + // expressed in units of base currency and scaled by BPS. 1e30 represents 1 USD. + accountData.healthFactor = Math.mulDiv( + accountData.avgCollateralFactor, + WadRayMath.WAD * (WadRayMath.RAY / PercentageMath.PERCENTAGE_FACTOR), + accountData.totalDebtValueRay, + Math.Rounding.Floor + ); } else { accountData.healthFactor = type(uint256).max; } if (accountData.totalCollateralValue > 0) { - accountData.avgCollateralFactor = accountData - .avgCollateralFactor - .wadDivDown(accountData.totalCollateralValue) - .fromBpsDown(); + accountData.avgCollateralFactor = accountData.avgCollateralFactor.mulDivDown( + WadRayMath.WAD / PercentageMath.PERCENTAGE_FACTOR, + accountData.totalCollateralValue + ); } // sort by collateral risk in ASC, collateral value in DESC collateralInfo.sortByKey(); // runs until either the collateral or debt is exhausted - uint256 debtValueLeftToCover = accountData.totalDebtValue; + uint256 totalDebtValue = accountData.totalDebtValueRay.fromRayUp(); + uint256 debtValueLeftToCover = totalDebtValue; for (uint256 index = 0; index < collateralInfo.length(); ++index) { if (debtValueLeftToCover == 0) { @@ -808,8 +812,8 @@ abstract contract Spoke is debtValueLeftToCover = debtValueLeftToCover.uncheckedSub(userCollateralValue); } - if (debtValueLeftToCover < accountData.totalDebtValue) { - accountData.riskPremium /= accountData.totalDebtValue.uncheckedSub(debtValueLeftToCover); + if (debtValueLeftToCover < totalDebtValue) { + accountData.riskPremium /= totalDebtValue.uncheckedSub(debtValueLeftToCover); } return accountData; @@ -864,8 +868,8 @@ abstract contract Spoke is uint256 assetId = reserve.assetId; uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); - (uint256 drawnDebtReported, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); - uint256 deficitShares = drawnDebtReported.rayDivDown(drawnIndex); + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay(drawnIndex); + uint256 deficitShares = drawnDebtRay / drawnIndex; IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ drawnSharesTaken: deficitShares, @@ -874,7 +878,7 @@ abstract contract Spoke is restoredPremiumRay: premiumDebtRay }); - hub.reportDeficit(assetId, drawnDebtReported, premiumDelta); + hub.reportDeficit(assetId, drawnDebtRay.fromRayUp(), premiumDelta); userPosition.applyPremiumDelta(premiumDelta); userPosition.drawnShares -= deficitShares.toUint120(); positionStatus.setBorrowing(reserveId, false); diff --git a/src/spoke/TreasurySpoke.sol b/src/spoke/TreasurySpoke.sol index 4b29c1315..3a1d57587 100644 --- a/src/spoke/TreasurySpoke.sol +++ b/src/spoke/TreasurySpoke.sol @@ -95,7 +95,7 @@ contract TreasurySpoke is ITreasurySpoke, Ownable2Step { function getUserTotalDebt(uint256, address) external pure returns (uint256) {} /// @inheritdoc ISpokeBase - function getUserPremiumDebtRay(uint256, address) external pure returns (uint256) {} + function getUserDebtRay(uint256, address) external pure returns (uint256, uint256) {} /// @inheritdoc ISpokeBase function getReserveSuppliedAssets(uint256 reserveId) external view returns (uint256) { diff --git a/src/spoke/interfaces/ISpoke.sol b/src/spoke/interfaces/ISpoke.sol index 6e3512112..a42397545 100644 --- a/src/spoke/interfaces/ISpoke.sol +++ b/src/spoke/interfaces/ISpoke.sol @@ -125,7 +125,7 @@ interface ISpoke is ISpokeBase, IAccessManaged, IIntentConsumer, IExtSload, IMul /// @dev avgCollateralFactor The weighted average collateral factor of the user position, expressed in WAD. /// @dev healthFactor The health factor of the user position, expressed in WAD. 1e18 represents a health factor of 1.00. /// @dev totalCollateralValue The total collateral value of the user position, expressed in units of base currency. 1e26 represents 1 USD. - /// @dev totalDebtValue The total debt value of the user position, expressed in units of base currency. 1e26 represents 1 USD. + /// @dev totalDebtValueRay The total debt value of the user position, expressed in units of base currency and scaled by RAY. 1e53 represents 1 USD. /// @dev activeCollateralCount The number of active collaterals, which includes reserves with `collateralFactor` > 0, `enabledAsCollateral` and `suppliedAmount` > 0. /// @dev borrowedCount The number of borrowed reserves of the user position. struct UserAccountData { @@ -133,7 +133,7 @@ interface ISpoke is ISpokeBase, IAccessManaged, IIntentConsumer, IExtSload, IMul uint256 avgCollateralFactor; uint256 healthFactor; uint256 totalCollateralValue; - uint256 totalDebtValue; + uint256 totalDebtValueRay; uint256 activeCollateralCount; uint256 borrowedCount; } diff --git a/src/spoke/interfaces/ISpokeBase.sol b/src/spoke/interfaces/ISpokeBase.sol index 370c6b141..bcd8ef4b0 100644 --- a/src/spoke/interfaces/ISpokeBase.sol +++ b/src/spoke/interfaces/ISpokeBase.sol @@ -72,11 +72,11 @@ interface ISpokeBase { /// @param user The address of the borrower getting liquidated. /// @param liquidator The address of the liquidator. /// @param receiveShares True if the liquidator received collateral in supplied shares rather than underlying assets. - /// @param debtToLiquidate The debt amount of borrowed reserve to be liquidated. - /// @param drawnSharesToLiquidate The amount of drawn shares to be liquidated. + /// @param debtAmountRestored The amount of debt restored, expressed in asset units. + /// @param drawnSharesLiquidated The amount of drawn shares liquidated. /// @param premiumDelta A struct representing the changes to premium debt after liquidation. - /// @param collateralToLiquidate The total amount of collateral asset to be liquidated, inclusive of liquidation fee. - /// @param collateralSharesToLiquidate The total amount of collateral shares to liquidate. + /// @param collateralAmountRemoved The amount of collateral removed, expressed in asset units. + /// @param collateralSharesLiquidated The total amount of collateral shares liquidated. /// @param collateralSharesToLiquidator The amount of collateral shares that the liquidator received. event LiquidationCall( uint256 indexed collateralReserveId, @@ -84,11 +84,11 @@ interface ISpokeBase { address indexed user, address liquidator, bool receiveShares, - uint256 debtToLiquidate, - uint256 drawnSharesToLiquidate, + uint256 debtAmountRestored, + uint256 drawnSharesLiquidated, IHubBase.PremiumDelta premiumDelta, - uint256 collateralToLiquidate, - uint256 collateralSharesToLiquidate, + uint256 collateralAmountRemoved, + uint256 collateralSharesLiquidated, uint256 collateralSharesToLiquidator ); @@ -226,10 +226,11 @@ interface ISpokeBase { /// @return The total debt amount. function getUserTotalDebt(uint256 reserveId, address user) external view returns (uint256); - /// @notice Returns the full precision premium debt of a specific user for a given reserve. + /// @notice Returns the full precision debt of a specific user for a given reserve. /// @dev It reverts if the reserve associated with the given reserve identifier is not listed. /// @param reserveId The identifier of the reserve. /// @param user The address of the user. + /// @return The amount of drawn debt, expressed in asset units and scaled by RAY. /// @return The amount of premium debt, expressed in asset units and scaled by RAY. - function getUserPremiumDebtRay(uint256 reserveId, address user) external view returns (uint256); + function getUserDebtRay(uint256 reserveId, address user) external view returns (uint256, uint256); } diff --git a/src/spoke/libraries/LiquidationLogic.sol b/src/spoke/libraries/LiquidationLogic.sol index dc7201bb5..4699fff26 100644 --- a/src/spoke/libraries/LiquidationLogic.sol +++ b/src/spoke/libraries/LiquidationLogic.sol @@ -2,11 +2,13 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.20; +import {Math} from 'src/dependencies/openzeppelin/Math.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.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 {IOU} from 'src/hub/libraries/IOU.sol'; import {PositionStatusMap} from 'src/spoke/libraries/PositionStatusMap.sol'; import {UserPositionDebt} from 'src/spoke/libraries/UserPositionDebt.sol'; import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap.sol'; @@ -26,18 +28,42 @@ library LiquidationLogic { using UserPositionDebt for ISpoke.UserPosition; using ReserveFlagsMap for ReserveFlags; using PositionStatusMap for ISpoke.PositionStatus; + using LiquidationLogic for uint256; struct LiquidateUserParams { uint256 collateralReserveId; uint256 debtReserveId; address oracle; address user; + ISpoke.LiquidationConfig liquidationConfig; uint256 debtToCover; uint256 healthFactor; - uint256 drawnDebt; - uint256 premiumDebtRay; - uint256 drawnIndex; - uint256 totalDebtValue; + uint256 totalDebtValueRay; + address liquidator; + uint256 activeCollateralCount; + uint256 borrowedCount; + 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; + uint256 healthFactor; + uint256 totalDebtValueRay; address liquidator; uint256 activeCollateralCount; uint256 borrowedCount; @@ -45,27 +71,43 @@ library LiquidationLogic { } struct LiquidateCollateralParams { - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; + IHubBase hub; + uint256 assetId; + uint256 sharesToLiquidate; + uint256 sharesToLiquidator; address liquidator; bool receiveShares; } + struct LiquidateCollateralResult { + uint256 amountRemoved; + bool isCollateralPositionEmpty; + } + struct LiquidateDebtParams { - uint256 debtReserveId; - uint256 debtToLiquidate; - uint256 premiumDebtRay; + IHubBase hub; + uint256 assetId; + address underlying; + uint256 reserveId; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; uint256 drawnIndex; address liquidator; } + struct LiquidateDebtResult { + uint256 amountRestored; + IHubBase.PremiumDelta premiumDelta; + bool isDebtPositionEmpty; + } + struct ValidateLiquidationCallParams { address user; address liquidator; ReserveFlags collateralReserveFlags; ReserveFlags debtReserveFlags; - uint256 collateralReserveBalance; - uint256 debtReserveBalance; + uint256 suppliedShares; + uint256 drawnShares; uint256 debtToCover; uint256 collateralFactor; bool isUsingAsCollateral; @@ -74,7 +116,7 @@ library LiquidationLogic { } struct CalculateDebtToTargetHealthFactorParams { - uint256 totalDebtValue; + uint256 totalDebtValueRay; uint256 debtAssetUnit; uint256 debtAssetPrice; uint256 collateralFactor; @@ -84,8 +126,11 @@ library LiquidationLogic { } struct CalculateDebtToLiquidateParams { - uint256 debtReserveBalance; - uint256 totalDebtValue; + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + uint256 totalDebtValueRay; + uint256 debtAssetDecimals; uint256 debtAssetUnit; uint256 debtAssetPrice; uint256 debtToCover; @@ -95,14 +140,31 @@ library LiquidationLogic { uint256 targetHealthFactor; } - struct CalculateLiquidationAmountsParams { - uint256 collateralReserveBalance; + struct CalculateCollateralToLiquidateParams { + IHubBase collateralReserveHub; + uint256 collateralReserveAssetId; uint256 collateralAssetUnit; uint256 collateralAssetPrice; - uint256 debtReserveBalance; - uint256 totalDebtValue; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + uint256 drawnIndex; uint256 debtAssetUnit; uint256 debtAssetPrice; + uint256 liquidationBonus; + } + + 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; uint256 collateralFactor; uint256 healthFactorForMaxBonus; @@ -114,9 +176,10 @@ library LiquidationLogic { } struct LiquidationAmounts { - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; - uint256 debtToLiquidate; + uint256 collateralSharesToLiquidate; + uint256 collateralSharesToLiquidator; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; } // see ISpoke.HEALTH_FACTOR_LIQUIDATION_THRESHOLD docs @@ -130,8 +193,7 @@ library LiquidationLogic { /// @param debtReserve The debt reserve to repay during liquidation. /// @param positions The mapping of positions per reserve per user. /// @param positionStatus The mapping of position status per user. - /// @param liquidationConfig The liquidation config. - /// @param collateralDynConfig The collateral dynamic config. + /// @param dynamicConfig The mapping of dynamic config per reserve per user. /// @param params The liquidate user params. /// @return True if the liquidation results in deficit. function liquidateUser( @@ -139,107 +201,53 @@ library LiquidationLogic { ISpoke.Reserve storage debtReserve, mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) storage positions, mapping(address user => ISpoke.PositionStatus) storage positionStatus, - ISpoke.LiquidationConfig storage liquidationConfig, - ISpoke.DynamicReserveConfig storage collateralDynConfig, + mapping(uint256 reserveId => mapping(uint24 dynamicConfigKey => ISpoke.DynamicReserveConfig)) storage dynamicConfig, LiquidateUserParams memory params ) external returns (bool) { - uint256 collateralReserveBalance = collateralReserve.hub.previewRemoveByShares( - collateralReserve.assetId, - positions[params.user][params.collateralReserveId].suppliedShares - ); - _validateLiquidationCall( - ValidateLiquidationCallParams({ - user: params.user, - liquidator: params.liquidator, - collateralReserveFlags: collateralReserve.flags, - debtReserveFlags: debtReserve.flags, - collateralReserveBalance: collateralReserveBalance, - debtReserveBalance: params.drawnDebt + params.premiumDebtRay.fromRayUp(), - debtToCover: params.debtToCover, - collateralFactor: collateralDynConfig.collateralFactor, - isUsingAsCollateral: positionStatus[params.user].isUsingAsCollateral( - params.collateralReserveId - ), - healthFactor: params.healthFactor, - receiveShares: params.receiveShares - }) - ); - - LiquidationAmounts memory liquidationAmounts = _calculateLiquidationAmounts( - CalculateLiquidationAmountsParams({ - collateralReserveBalance: collateralReserveBalance, - collateralAssetUnit: MathUtils.uncheckedExp(10, collateralReserve.decimals), - collateralAssetPrice: IAaveOracle(params.oracle).getReservePrice( - params.collateralReserveId - ), - debtReserveBalance: params.drawnDebt + params.premiumDebtRay.fromRayUp(), - totalDebtValue: params.totalDebtValue, - debtAssetUnit: MathUtils.uncheckedExp(10, debtReserve.decimals), - debtAssetPrice: IAaveOracle(params.oracle).getReservePrice(params.debtReserveId), - debtToCover: params.debtToCover, - collateralFactor: collateralDynConfig.collateralFactor, - healthFactorForMaxBonus: liquidationConfig.healthFactorForMaxBonus, - liquidationBonusFactor: liquidationConfig.liquidationBonusFactor, - maxLiquidationBonus: collateralDynConfig.maxLiquidationBonus, - targetHealthFactor: liquidationConfig.targetHealthFactor, - healthFactor: params.healthFactor, - liquidationFee: collateralDynConfig.liquidationFee - }) - ); - - ( - uint256 collateralSharesToLiquidate, - uint256 collateralSharesToLiquidator, - bool isCollateralPositionEmpty - ) = _liquidateCollateral( - collateralReserve, - positions[params.user][params.collateralReserveId], - positions[params.liquidator][params.collateralReserveId], - LiquidateCollateralParams({ - collateralToLiquidate: liquidationAmounts.collateralToLiquidate, - collateralToLiquidator: liquidationAmounts.collateralToLiquidator, - liquidator: params.liquidator, - receiveShares: params.receiveShares - }) - ); - - ( - uint256 drawnSharesToLiquidate, - IHubBase.PremiumDelta memory premiumDelta, - bool isDebtPositionEmpty - ) = _liquidateDebt( - debtReserve, - positions[params.user][params.debtReserveId], - positionStatus[params.user], - LiquidateDebtParams({ - debtReserveId: params.debtReserveId, - debtToLiquidate: liquidationAmounts.debtToLiquidate, - premiumDebtRay: params.premiumDebtRay, - drawnIndex: params.drawnIndex, - liquidator: params.liquidator - }) - ); + ISpoke.UserPosition storage collateralUserPosition = positions[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, + healthFactor: params.healthFactor, + totalDebtValueRay: params.totalDebtValueRay, + liquidator: params.liquidator, + activeCollateralCount: params.activeCollateralCount, + borrowedCount: params.borrowedCount, + receiveShares: params.receiveShares + }); - emit ISpokeBase.LiquidationCall( - params.collateralReserveId, - params.debtReserveId, - params.user, - params.liquidator, - params.receiveShares, - liquidationAmounts.debtToLiquidate, - drawnSharesToLiquidate, - premiumDelta, - liquidationAmounts.collateralToLiquidate, - collateralSharesToLiquidate, - collateralSharesToLiquidator - ); + ISpoke.UserPosition storage debtUserPosition = positions[params.user][params.debtReserveId]; + ISpoke.UserPosition storage collateralLiquidatorPosition = positions[params.liquidator][ + params.collateralReserveId + ]; + ISpoke.PositionStatus storage userPositionStatus = positionStatus[params.user]; return - _evaluateDeficit({ - isCollateralPositionEmpty: isCollateralPositionEmpty, - isDebtPositionEmpty: isDebtPositionEmpty, - activeCollateralCount: params.activeCollateralCount, - borrowedCount: params.borrowedCount + _executeLiquidation({ + collateralUserPosition: collateralUserPosition, + debtUserPosition: debtUserPosition, + collateralLiquidatorPosition: collateralLiquidatorPosition, + userPositionStatus: userPositionStatus, + params: executeLiquidationParams }); } @@ -273,79 +281,221 @@ library LiquidationLogic { ); } + /// @notice Converts an asset amount to base currency value. 1e26 represents 1 USD. + /// @dev Assumes asset uses at most 18 decimals. Reverts if multiplication overflows. + /// @param amount The asset amount. + /// @param decimals The decimals of the asset. + /// @param price The price of the asset. + /// @return The base currency value. + function toValue( + uint256 amount, + uint256 decimals, + uint256 price + ) internal pure returns (uint256) { + return + amount * MathUtils.uncheckedExp(10, WadRayMath.WAD_DECIMALS.uncheckedSub(decimals)) * price; + } + + /// @dev Executes the liquidation. + /// @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; + UserPositionDebt.DebtComponents memory debtComponents = debtUserPosition.getDebtComponents( + params.debtHub, + params.debtAssetId + ); + + _validateLiquidationCall( + 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 + }) + ); + + 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, + 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 + }) + ); + + LiquidateCollateralResult memory liquidateCollateralResult = _liquidateCollateral( + collateralUserPosition, + collateralLiquidatorPosition, + LiquidateCollateralParams({ + hub: params.collateralHub, + assetId: params.collateralAssetId, + sharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate, + sharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator, + liquidator: params.liquidator, + receiveShares: params.receiveShares + }) + ); + + LiquidateDebtResult memory liquidateDebtResult = _liquidateDebt( + debtUserPosition, + userPositionStatus, + 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 ISpokeBase.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 + _evaluateDeficit({ + isCollateralPositionEmpty: liquidateCollateralResult.isCollateralPositionEmpty, + isDebtPositionEmpty: liquidateDebtResult.isDebtPositionEmpty, + activeCollateralCount: params.activeCollateralCount, + borrowedCount: params.borrowedCount + }); + } + /// @dev Invoked by `liquidateUser` method. - /// @return The total amount of collateral shares to be liquidated. - /// @return The amount of collateral shares that the liquidator receives. - /// @return True if the user collateral position becomes empty after removing. + /// @return The liquidate collateral result. function _liquidateCollateral( - ISpoke.Reserve storage collateralReserve, - ISpoke.UserPosition storage collateralPosition, - ISpoke.UserPosition storage liquidatorCollateralPosition, + ISpoke.UserPosition storage userPosition, + ISpoke.UserPosition storage liquidatorPosition, LiquidateCollateralParams memory params - ) internal returns (uint256, uint256, bool) { - IHubBase hub = collateralReserve.hub; - uint256 assetId = collateralReserve.assetId; - - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint120 userSuppliedShares = collateralPosition.suppliedShares - sharesToLiquidate.toUint120(); + ) internal returns (LiquidateCollateralResult memory) { + uint120 newUserSuppliedShares = userPosition.suppliedShares - + params.sharesToLiquidate.toUint120(); + userPosition.suppliedShares = newUserSuppliedShares; + + uint256 amountRemoved = params.hub.previewRemoveByShares( + params.assetId, + params.sharesToLiquidate + ); - uint256 sharesToLiquidator; - if (params.collateralToLiquidator > 0) { + if (params.sharesToLiquidator > 0) { if (params.receiveShares) { - sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - if (sharesToLiquidator > 0) { - liquidatorCollateralPosition.suppliedShares += sharesToLiquidator.toUint120(); - } + liquidatorPosition.suppliedShares += params.sharesToLiquidator.toUint120(); } else { - sharesToLiquidator = hub.remove(assetId, params.collateralToLiquidator, params.liquidator); + uint256 amountToLiquidator = amountRemoved; + if (params.sharesToLiquidator != params.sharesToLiquidate) { + amountToLiquidator = params.hub.previewRemoveByShares( + params.assetId, + params.sharesToLiquidator + ); + } + params.hub.remove(params.assetId, amountToLiquidator, params.liquidator); } } - collateralPosition.suppliedShares = userSuppliedShares; - - if (sharesToLiquidate > sharesToLiquidator) { - hub.payFeeShares(assetId, sharesToLiquidate.uncheckedSub(sharesToLiquidator)); + uint256 feeShares = params.sharesToLiquidate - params.sharesToLiquidator; + if (feeShares > 0) { + params.hub.payFeeShares(params.assetId, feeShares); } - return (sharesToLiquidate, sharesToLiquidator, userSuppliedShares == 0); + return + LiquidateCollateralResult({ + amountRemoved: amountRemoved, + isCollateralPositionEmpty: newUserSuppliedShares == 0 + }); } /// @dev Invoked by `liquidateUser` method. - /// @return The amount of drawn shares to be liquidated. - /// @return A struct representing the changes to premium debt after liquidation. - /// @return True if the debt position becomes zero after restoring. + /// @return The liquidate debt result. function _liquidateDebt( - ISpoke.Reserve storage debtReserve, - ISpoke.UserPosition storage debtPosition, + ISpoke.UserPosition storage userPosition, ISpoke.PositionStatus storage positionStatus, LiquidateDebtParams memory params - ) internal returns (uint256, IHubBase.PremiumDelta memory, bool) { - uint256 premiumDebtToLiquidateRay = params.debtToLiquidate.toRay().min(params.premiumDebtRay); - uint256 drawnDebtLiquidated = params.debtToLiquidate - premiumDebtToLiquidateRay.fromRayUp(); - uint256 drawnSharesLiquidated = drawnDebtLiquidated.rayDivDown(params.drawnIndex); - - IHubBase.PremiumDelta memory premiumDelta = debtPosition.calculatePremiumDelta({ - drawnSharesTaken: drawnSharesLiquidated, + ) internal returns (LiquidateDebtResult memory) { + IHubBase.PremiumDelta memory premiumDelta = userPosition.calculatePremiumDelta({ + drawnSharesTaken: params.drawnSharesToLiquidate, drawnIndex: params.drawnIndex, riskPremium: positionStatus.riskPremium, - restoredPremiumRay: premiumDebtToLiquidateRay + restoredPremiumRay: params.premiumDebtRayToLiquidate }); - IERC20(debtReserve.underlying).safeTransferFrom( + uint256 amountToRestore = (IOU.calculateDrawnRay( + params.drawnSharesToLiquidate, + params.drawnIndex + ) + params.premiumDebtRayToLiquidate).fromRayUp(); + IERC20(params.underlying).safeTransferFrom( params.liquidator, - address(debtReserve.hub), - params.debtToLiquidate + address(params.hub), + amountToRestore + ); + params.hub.restore( + params.assetId, + params.drawnSharesToLiquidate.rayMulUp(params.drawnIndex), + premiumDelta ); - debtReserve.hub.restore(debtReserve.assetId, drawnDebtLiquidated, premiumDelta); - debtPosition.applyPremiumDelta(premiumDelta); - debtPosition.drawnShares -= drawnSharesLiquidated.toUint120(); - if (debtPosition.drawnShares == 0) { - positionStatus.setBorrowing(params.debtReserveId, false); - return (drawnSharesLiquidated, premiumDelta, true); + userPosition.applyPremiumDelta(premiumDelta); + userPosition.drawnShares -= params.drawnSharesToLiquidate.toUint120(); + + bool isDebtPositionEmpty; + if (userPosition.drawnShares == 0) { + positionStatus.setBorrowing(params.reserveId, false); + isDebtPositionEmpty = true; } - return (drawnSharesLiquidated, premiumDelta, false); + return + LiquidateDebtResult({ + amountRestored: amountToRestore, + premiumDelta: premiumDelta, + isDebtPositionEmpty: isDebtPositionEmpty + }); } /// @notice Validates the liquidation call. @@ -357,8 +507,10 @@ library LiquidationLogic { !params.collateralReserveFlags.paused() && !params.debtReserveFlags.paused(), ISpoke.ReservePaused() ); - require(params.collateralReserveBalance > 0, ISpoke.ReserveNotSupplied()); - require(params.debtReserveBalance > 0, ISpoke.ReserveNotBorrowed()); + require(params.suppliedShares > 0, ISpoke.ReserveNotSupplied()); + // user has active debt if and only if user has drawn shares (premium debt is always repaid first, + // and can only be created when drawn shares exist) + require(params.drawnShares > 0, ISpoke.ReserveNotBorrowed()); require( params.healthFactor < HEALTH_FACTOR_LIQUIDATION_THRESHOLD, ISpoke.HealthFactorNotBelowThreshold() @@ -380,7 +532,10 @@ library LiquidationLogic { /// @dev Invoked by `liquidateUser` method. function _calculateLiquidationAmounts( CalculateLiquidationAmountsParams memory params - ) internal pure returns (LiquidationAmounts memory) { + ) internal view returns (LiquidationAmounts memory) { + uint256 collateralAssetUnit = MathUtils.uncheckedExp(10, params.collateralAssetDecimals); + uint256 debtAssetUnit = MathUtils.uncheckedExp(10, params.debtAssetDecimals); + uint256 liquidationBonus = calculateLiquidationBonus({ healthFactorForMaxBonus: params.healthFactorForMaxBonus, liquidationBonusFactor: params.liquidationBonusFactor, @@ -392,11 +547,14 @@ library LiquidationLogic { // 1. liquidate all debt // 2. liquidate all collateral // 3. leave at least `DUST_LIQUIDATION_THRESHOLD` of collateral and debt (in value terms) - uint256 debtToLiquidate = _calculateDebtToLiquidate( + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = _calculateDebtToLiquidate( CalculateDebtToLiquidateParams({ - debtReserveBalance: params.debtReserveBalance, - totalDebtValue: params.totalDebtValue, - debtAssetUnit: params.debtAssetUnit, + 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, @@ -406,65 +564,154 @@ library LiquidationLogic { }) ); - uint256 collateralToLiquidate = debtToLiquidate.mulDivDown( - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus, - params.debtAssetUnit * params.collateralAssetPrice * PercentageMath.PERCENTAGE_FACTOR + uint256 collateralSharesToLiquidate = _calculateCollateralToLiquidate( + 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 = collateralToLiquidate < params.collateralReserveBalance && - (params.collateralReserveBalance - collateralToLiquidate).mulDivDown( - params.collateralAssetPrice.toWad(), - params.collateralAssetUnit - ) < - DUST_LIQUIDATION_THRESHOLD; + bool leavesCollateralDust; + if (collateralSharesToLiquidate < params.suppliedShares) { + uint256 remainingCollateralBalance = params.collateralReserveHub.previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares.uncheckedSub(collateralSharesToLiquidate) + ); + leavesCollateralDust = + remainingCollateralBalance.toValue({ + decimals: params.collateralAssetDecimals, + price: params.collateralAssetPrice + }) < DUST_LIQUIDATION_THRESHOLD; + } + // debt is fully liquidated if and only if all drawn shares are liquidated if ( - collateralToLiquidate > params.collateralReserveBalance || - (leavesCollateralDust && debtToLiquidate < params.debtReserveBalance) + collateralSharesToLiquidate > params.suppliedShares || + (leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares) ) { - collateralToLiquidate = params.collateralReserveBalance; - - // - `debtToLiquidate` is decreased if `collateralToLiquidate > params.collateralReserveBalance` (if so, debt dust could remain). - // - `debtToLiquidate` is increased if `(leavesCollateralDust && debtToLiquidate < params.debtReserveBalance)`, ensuring collateral reserve - // is fully liquidated (potentially bypassing the target health factor). Can only increase by at most `DUST_LIQUIDATION_THRESHOLD` (in - // value terms). Since debt dust condition was enforced, it is guaranteed that `debtToLiquidate` will never exceed `params.debtReserveBalance`. - debtToLiquidate = collateralToLiquidate.mulDivUp( - params.collateralAssetPrice * params.debtAssetUnit * PercentageMath.PERCENTAGE_FACTOR, - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus + collateralSharesToLiquidate = params.suppliedShares; + + // - `debtRayToLiquidate` is decreased if `collateralSharesToLiquidate > params.suppliedShares` (if so, debt dust could remain). + // - `debtRayToLiquidate` is increased if `(leavesCollateralDust && drawnSharesToLiquidate < params.drawnShares)`, + // ensuring collateral reserve 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 be more than debtRayToLiquidate in order to utilize all assets + premiumDebtRayToLiquidate = debtRayToLiquidate.fromRayUp().toRay().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 `params.suppliedShares` due to roundings. + // If this happens, simply cap `collateralSharesToLiquidate` to `params.suppliedShares` since + // debt to liquidate would be the same (it is already calculated based on `params.suppliedShares`). + collateralSharesToLiquidate = _calculateCollateralToLiquidate( + 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(params.suppliedShares); + } + } } - // revert if the liquidator does not cover the necessary debt to prevent dust from remaining - require(params.debtToCover >= debtToLiquidate, ISpoke.MustNotLeaveDust()); + // 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 collateralToLiquidator = collateralToLiquidate - - collateralToLiquidate.mulDivDown( + uint256 collateralSharesToLiquidator = collateralSharesToLiquidate - + collateralSharesToLiquidate.mulDivDown( params.liquidationFee * (liquidationBonus - PercentageMath.PERCENTAGE_FACTOR), liquidationBonus * PercentageMath.PERCENTAGE_FACTOR ); return LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } - /// @notice Calculates the debt that should be liquidated. - /// @dev Generally, it returns the minimum of `debtToCover`, `debtReserveBalance` and `debtToTarget`. - /// If debt dust would be left behind, it returns `debtReserveBalance` to ensure the debt is fully cleared and no dust is left. + /// @notice Calculates the amount of collateral shares that should be liquidated based on liquidated debt. + /// @return The amount of collateral shares that should be liquidated. + function _calculateCollateralToLiquidate( + CalculateCollateralToLiquidateParams memory params + ) internal view returns (uint256) { + uint256 debtRayToLiquidate = IOU.calculateDrawnRay( + params.drawnSharesToLiquidate, + params.drawnIndex + ) + params.premiumDebtRayToLiquidate; + + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * params.collateralAssetUnit * params.liquidationBonus, + params.debtAssetUnit * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor + ); + + return + params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralToLiquidate + ); + } + + /// @notice Calculates the amount of drawn shares and premium debt that should be liquidated. + /// @dev Returned values do not exceed `params.drawnShares` and `params.premiumDebtRay`. + /// @dev Total assets required to liquidate the returned amount of drawn and premium debt does not exceed `params.debtToCover`, + /// but they may exceed `debtToTarget` to ensure debt after liquidation decreased by at least `debtToTarget`. + /// @dev If debt dust would be left behind, the full amounts of `params.drawnShares` and `params.premiumDebtRay` are returned. function _calculateDebtToLiquidate( CalculateDebtToLiquidateParams memory params - ) internal pure returns (uint256) { - uint256 debtToLiquidate = params.debtReserveBalance; - if (params.debtToCover < debtToLiquidate) { - debtToLiquidate = params.debtToCover; - } - - uint256 debtToTarget = _calculateDebtToTargetHealthFactor( + ) internal pure returns (uint256, uint256) { + uint256 debtRayToTarget = _calculateDebtToTargetHealthFactor( CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: params.totalDebtValue, + totalDebtValueRay: params.totalDebtValueRay, debtAssetUnit: params.debtAssetUnit, debtAssetPrice: params.debtAssetPrice, collateralFactor: params.collateralFactor, @@ -473,23 +720,54 @@ library LiquidationLogic { targetHealthFactor: params.targetHealthFactor }) ); - if (debtToTarget < debtToLiquidate) { - debtToLiquidate = debtToTarget; + + uint256 premiumDebtRayToLiquidate = debtRayToTarget.fromRayUp().toRay().min( + params.premiumDebtRay + ); + if (params.debtToCover <= premiumDebtRayToLiquidate.fromRayDown()) { + premiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToLiquidate; + if ( + premiumDebtRayToLiquidate == params.premiumDebtRay && + premiumDebtRayToLiquidate < debtRayToTarget + ) { + uint256 drawnSharesToTarget = (debtRayToTarget - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + (uint256 drawnSharesToCover, ) = UserPositionDebt.calculateRestoreAmount({ + drawnDebtRay: IOU.calculateDrawnRay(params.drawnShares, params.drawnIndex), + premiumDebtRay: premiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex, + amount: params.debtToCover + }); + + drawnSharesToLiquidate = drawnSharesToTarget.min(drawnSharesToCover).min(params.drawnShares); } - bool leavesDebtDust = debtToLiquidate < params.debtReserveBalance && - (params.debtReserveBalance - debtToLiquidate).mulDivDown( - params.debtAssetPrice.toWad(), - params.debtAssetUnit - ) < + uint256 debtRayRemaining = IOU.calculateDrawnRay( + 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.fromRayDown().toValue({ + decimals: params.debtAssetDecimals, + price: params.debtAssetPrice + }) < DUST_LIQUIDATION_THRESHOLD; if (leavesDebtDust) { // target health factor is bypassed to prevent leaving dust - debtToLiquidate = params.debtReserveBalance; + drawnSharesToLiquidate = params.drawnShares; + premiumDebtRayToLiquidate = params.premiumDebtRay; } - return debtToLiquidate; + return (drawnSharesToLiquidate, premiumDebtRayToLiquidate); } /// @notice Calculates the amount of debt needed to be liquidated to restore a position to the target health factor. @@ -504,9 +782,11 @@ library LiquidationLogic { // `liquidationBonus.percentMulUp(collateralFactor) < PercentageMath.PERCENTAGE_FACTOR` is enforced in `_validateDynamicReserveConfig` // and targetHealthFactor is always >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD return - params.totalDebtValue.mulDivUp( + Math.mulDiv( + params.totalDebtValueRay, params.debtAssetUnit * (params.targetHealthFactor - params.healthFactor), - (params.targetHealthFactor - liquidationPenalty) * params.debtAssetPrice.toWad() + (params.targetHealthFactor - liquidationPenalty) * params.debtAssetPrice * WadRayMath.WAD, + Math.Rounding.Ceil ); } diff --git a/src/spoke/libraries/UserPositionDebt.sol b/src/spoke/libraries/UserPositionDebt.sol index ab497edd2..c650d2c17 100644 --- a/src/spoke/libraries/UserPositionDebt.sol +++ b/src/spoke/libraries/UserPositionDebt.sol @@ -6,7 +6,7 @@ import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {PercentageMath} from 'src/libraries/math/PercentageMath.sol'; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; -import {Premium} from 'src/hub/libraries/Premium.sol'; +import {IOU} from 'src/hub/libraries/IOU.sol'; import {IHubBase} from 'src/hub/interfaces/IHubBase.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; @@ -20,6 +20,16 @@ library UserPositionDebt { using WadRayMath for *; using MathUtils for *; + /// @notice Debt components of a user position. + /// @dev drawnShares The amount of drawn shares. + /// @dev premiumDebtRay The amount of premium debt, expressed in asset units and scaled by RAY. + /// @dev drawnIndex The drawn index of the reserve, expressed in RAY. + struct DebtComponents { + uint256 drawnShares; + uint256 premiumDebtRay; + uint256 drawnIndex; + } + /// @notice Applies the premium delta to the user position. /// @param userPosition The user position. /// @param premiumDelta The premium delta to apply. @@ -51,7 +61,7 @@ library UserPositionDebt { ) internal view returns (IHubBase.PremiumDelta memory) { uint256 oldPremiumShares = userPosition.premiumShares; int256 oldPremiumOffsetRay = userPosition.premiumOffsetRay; - uint256 premiumDebtRay = Premium.calculatePremiumRay({ + uint256 premiumDebtRay = IOU.calculatePremiumRay({ premiumShares: oldPremiumShares, premiumOffsetRay: oldPremiumOffsetRay, drawnIndex: drawnIndex @@ -75,45 +85,57 @@ library UserPositionDebt { /// @param userPosition The user position. /// @param drawnIndex The drawn index of the reserve. /// @param amount The amount to restore. - /// @return The amount of drawn debt to restore, expressed in asset units. + /// @return The amount of drawn shares to restore. /// @return The amount of premium debt to restore, expressed in asset units and scaled by RAY. function calculateRestoreAmount( ISpoke.UserPosition storage userPosition, uint256 drawnIndex, uint256 amount ) internal view returns (uint256, uint256) { - (uint256 drawnDebt, uint256 premiumDebtRay) = userPosition.getDebt(drawnIndex); - uint256 premiumDebt = premiumDebtRay.fromRayUp(); - if (amount >= drawnDebt + premiumDebt) { - return (drawnDebt, premiumDebtRay); - } + (uint256 drawnDebtRay, uint256 premiumDebtRay) = userPosition.getDebtRay(drawnIndex); + return calculateRestoreAmount(drawnDebtRay, premiumDebtRay, drawnIndex, amount); + } - if (amount < premiumDebt) { - // amount.toRay() cannot overflow here - uint256 amountRay = amount.toRay(); - return (0, amountRay); + /// @dev Calculates the amount of drawn shares and premium debt to restore for the given drawn debt and premium debt. + /// @param drawnDebtRay The drawn debt, expressed in asset units and scaled by RAY. + /// @param premiumDebtRay The premium debt, expressed in asset units and scaled by RAY. + /// @param drawnIndex The drawn index of the reserve. + /// @param amount The amount to restore. + /// @return The amount of drawn shares to restore. + /// @return The amount of premium debt to restore, expressed in asset units and scaled by RAY. + function calculateRestoreAmount( + uint256 drawnDebtRay, + uint256 premiumDebtRay, + uint256 drawnIndex, + uint256 amount + ) internal pure returns (uint256, uint256) { + if (amount >= (drawnDebtRay + premiumDebtRay).fromRayUp()) { + return (drawnDebtRay.fromRayUp(), premiumDebtRay); } - return (amount - premiumDebt, premiumDebtRay); + + uint256 premiumDebtRayToRestore = amount.toRay().min(premiumDebtRay); + uint256 drawnSharesToRestore = (amount.toRay() - premiumDebtRayToRestore) / drawnIndex; + return (drawnSharesToRestore, premiumDebtRayToRestore); } - /// @return The user's drawn debt, expressed in asset units. + /// @return The user's drawn debt, expressed in asset units and scaled by RAY. /// @return The user's premium debt, expressed in asset units and scaled by RAY. - function getDebt( + function getDebtRay( ISpoke.UserPosition storage userPosition, IHubBase hub, uint256 assetId ) internal view returns (uint256, uint256) { - return userPosition.getDebt(hub.getAssetDrawnIndex(assetId)); + return userPosition.getDebtRay(hub.getAssetDrawnIndex(assetId)); } - /// @return The user's drawn debt, expressed in asset units. + /// @return The user's drawn debt, expressed in asset units and scaled by RAY. /// @return The user's premium debt, expressed in asset units and scaled by RAY. - function getDebt( + function getDebtRay( ISpoke.UserPosition storage userPosition, uint256 drawnIndex ) internal view returns (uint256, uint256) { uint256 premiumDebtRay = _calculatePremiumRay(userPosition, drawnIndex); - return (userPosition.drawnShares.rayMulUp(drawnIndex), premiumDebtRay); + return (IOU.calculateDrawnRay(userPosition.drawnShares, drawnIndex), premiumDebtRay); } /// @dev Calculates the premium debt of a user position with full precision. @@ -125,10 +147,25 @@ library UserPositionDebt { uint256 drawnIndex ) internal view returns (uint256) { return - Premium.calculatePremiumRay({ + IOU.calculatePremiumRay({ premiumShares: userPosition.premiumShares, premiumOffsetRay: userPosition.premiumOffsetRay, drawnIndex: drawnIndex }); } + + /// @return The debt components of the user position. + function getDebtComponents( + ISpoke.UserPosition storage userPosition, + IHubBase hub, + uint256 assetId + ) internal view returns (DebtComponents memory) { + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + return + DebtComponents({ + drawnShares: userPosition.drawnShares, + premiumDebtRay: _calculatePremiumRay(userPosition, drawnIndex), + drawnIndex: drawnIndex + }); + } } diff --git a/tests/Base.t.sol b/tests/Base.t.sol index 42e7c7fb8..1dac196ca 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -27,6 +27,7 @@ import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; import {AuthorityUtils} from 'src/dependencies/openzeppelin/AuthorityUtils.sol'; import {Ownable2Step, Ownable} from 'src/dependencies/openzeppelin/Ownable2Step.sol'; import {Math} from 'src/dependencies/openzeppelin/Math.sol'; +import {SlotDerivation} from 'src/dependencies/openzeppelin/SlotDerivation.sol'; import {WETH9} from 'src/dependencies/weth/WETH9.sol'; import {LibBit} from 'src/dependencies/solady/LibBit.sol'; @@ -121,6 +122,9 @@ abstract contract Base is Test { uint256 internal constant MAX_SUPPLY_IN_BASE_CURRENCY = 1e39; uint24 internal constant MIN_COLLATERAL_RISK_BPS = 1; uint24 internal constant MAX_COLLATERAL_RISK_BPS = 1000_00; + uint256 internal constant MAX_SUPPLY_PRICE = 100; + uint256 internal constant MIN_DRAWN_INDEX = WadRayMath.RAY; + uint256 internal constant MAX_DRAWN_INDEX = 100 * WadRayMath.RAY; uint256 internal constant MAX_BORROW_RATE = 1000_00; // matches AssetInterestRateStrategy uint256 internal constant MIN_OPTIMAL_RATIO = 1_00; // 1.00% in BPS, matches AssetInterestRateStrategy uint256 internal constant MAX_OPTIMAL_RATIO = 99_00; // 99.00% in BPS, matches AssetInterestRateStrategy @@ -227,6 +231,7 @@ abstract contract Base is Test { struct Debts { uint256 drawnDebt; uint256 premiumDebt; + uint256 premiumDebtRay; uint256 totalDebt; } @@ -1207,6 +1212,15 @@ abstract contract Base is Test { assertEq(_getLatestDynamicReserveConfig(spoke, reserveId), config); } + function _addDynamicReserveConfig( + ISpoke spoke, + uint256 reserveId, + ISpoke.DynamicReserveConfig memory config + ) internal pausePrank returns (uint24) { + vm.prank(SPOKE_ADMIN); + return spoke.addDynamicReserveConfig(reserveId, config); + } + function _updateReserveBorrowableFlag( ISpoke spoke, uint256 reserveId, @@ -1254,6 +1268,18 @@ abstract contract Base is Test { assertEq(spoke.getLiquidationConfig(), liqConfig); } + function _updateLiquidationBonusFactor( + ISpoke spoke, + uint16 newLiquidationBonusFactor + ) internal pausePrank { + ISpoke.LiquidationConfig memory liqConfig = spoke.getLiquidationConfig(); + liqConfig.liquidationBonusFactor = newLiquidationBonusFactor; + vm.prank(SPOKE_ADMIN); + spoke.updateLiquidationConfig(liqConfig); + + assertEq(spoke.getLiquidationConfig(), liqConfig); + } + function getTargetHealthFactor(ISpoke spoke) internal view returns (uint256) { ISpoke.LiquidationConfig memory liqConfig = spoke.getLiquidationConfig(); return liqConfig.targetHealthFactor; @@ -1384,6 +1410,7 @@ abstract contract Base is Test { uint256 reserveId ) internal view returns (Debts memory data) { (data.drawnDebt, data.premiumDebt) = spoke.getUserDebt(reserveId, user); + data.premiumDebtRay = spoke.getUserPremiumDebtRay(reserveId, user); data.totalDebt = data.drawnDebt + data.premiumDebt; } @@ -1515,30 +1542,6 @@ abstract contract Base is Test { assertEq(newRate, oldRate, string.concat('debt rate should be constant ', label)); } - /// returns the USD value of the reserve normalized by it's decimals, in terms of WAD - function _getValue( - ISpoke spoke, - uint256 reserveId, - uint256 amount - ) internal view returns (uint256) { - return - (amount * IPriceOracle(spoke.ORACLE()).getReservePrice(reserveId)).wadDivDown( - 10 ** _underlying(spoke, reserveId).decimals() - ); - } - - /// returns the USD value of the reserve normalized by it's decimals, in terms of WAD - function _getDebtValue( - ISpoke spoke, - uint256 reserveId, - uint256 amount - ) internal view returns (uint256) { - return - (amount * IPriceOracle(spoke.ORACLE()).getReservePrice(reserveId)).wadDivUp( - 10 ** _underlying(spoke, reserveId).decimals() - ); - } - /// @notice Convert 1 asset amount to equivalent amount in another asset. /// @notice Will contain precision loss due to conversion split into two steps. /// @return Converted amount of toAsset. @@ -1592,23 +1595,13 @@ abstract contract Base is Test { function _calculateRestoreAmounts( uint256 restoreAmount, uint256 drawn, - uint256 premium - ) internal pure returns (uint256 baseAmount, uint256 premiumAmount) { - if (restoreAmount <= premium) { - return (0, restoreAmount); + uint256 premiumRay + ) internal pure returns (uint256 drawnAmountToRestore, uint256 premiumRayToRestore) { + if (restoreAmount <= premiumRay.fromRayDown()) { + return (0, restoreAmount.toRay()); } - return (drawn.min(restoreAmount - premium), premium); - } - - function _calculateRestoreAmounts( - ISpoke spoke, - uint256 reserveId, - address user, - uint256 repayAmount - ) internal view returns (uint256 baseAmount, uint256 premiumAmount) { - (uint256 userDrawnDebt, uint256 userPremiumDebt) = spoke.getUserDebt(reserveId, user); - return _calculateRestoreAmounts(repayAmount, userDrawnDebt, userPremiumDebt); + return (drawn.min(restoreAmount - premiumRay.fromRayUp()), premiumRay); } function _getExpectedPremiumDelta( @@ -1665,22 +1658,14 @@ abstract contract Base is Test { uint256 repayAmount ) internal view virtual returns (IHubBase.PremiumDelta memory) { Debts memory userDebt = getUserDebt(spoke, user, reserveId); - (, uint256 premiumAmountToRestore) = _calculateRestoreAmounts( + (, uint256 premiumRayToRestore) = _calculateRestoreAmounts( repayAmount, userDebt.drawnDebt, - userDebt.premiumDebt + userDebt.premiumDebtRay ); ISpoke.UserPosition memory userPosition = spoke.getUserPosition(reserveId, user); uint256 assetId = spoke.getReserve(reserveId).assetId; - uint256 premiumDebtRay = _calculatePremiumDebtRay( - hub1, - assetId, - userPosition.premiumShares, - userPosition.premiumOffsetRay - ); - - uint256 restoredPremiumRay = (premiumAmountToRestore * WadRayMath.RAY).min(premiumDebtRay); return _getExpectedPremiumDelta({ @@ -1690,7 +1675,7 @@ abstract contract Base is Test { oldPremiumOffsetRay: userPosition.premiumOffsetRay, drawnShares: 0, // risk premium is 0, so drawn shares do not matter here (otherwise they need to be updated with restored drawn shares amount) riskPremium: 0, - restoredPremiumRay: restoredPremiumRay + restoredPremiumRay: premiumRayToRestore }); } @@ -1702,24 +1687,17 @@ abstract contract Base is Test { uint256 repayAmount ) internal view virtual returns (IHubBase.PremiumDelta memory) { Debts memory userDebt = getUserDebt(spoke, user, reserveId); - (uint256 drawnDebtToRestore, uint256 premiumAmountToRestore) = _calculateRestoreAmounts( + (uint256 drawnDebtToRestore, uint256 premiumRayToRestore) = _calculateRestoreAmounts( repayAmount, userDebt.drawnDebt, - userDebt.premiumDebt + userDebt.premiumDebtRay ); { ISpoke.UserPosition memory userPosition = spoke.getUserPosition(reserveId, user); uint256 assetId = spoke.getReserve(reserveId).assetId; IHub hub = IHub(address(spoke.getReserve(reserveId).hub)); - uint256 premiumDebtRay = _calculatePremiumDebtRay( - hub, - assetId, - userPosition.premiumShares, - userPosition.premiumOffsetRay - ); - uint256 restoredPremiumRay = (premiumAmountToRestore * WadRayMath.RAY).min(premiumDebtRay); uint256 restoredShares = drawnDebtToRestore.rayDivDown(hub.getAssetDrawnIndex(assetId)); uint256 riskPremium = _getUserLastRiskPremium(spoke, user); @@ -1731,7 +1709,7 @@ abstract contract Base is Test { oldPremiumOffsetRay: userPosition.premiumOffsetRay, drawnShares: userPosition.drawnShares - restoredShares, riskPremium: riskPremium, - restoredPremiumRay: restoredPremiumRay + restoredPremiumRay: premiumRayToRestore }); } } @@ -2016,7 +1994,7 @@ abstract contract Base is Test { uint256 assetPrice, uint256 assetUnit ) internal pure returns (uint256) { - return (amount * assetPrice).wadDivUp(assetUnit); + return (amount * assetPrice) * (WadRayMath.WAD / assetUnit); } function _convertValueToAmount( @@ -2040,6 +2018,21 @@ abstract contract Base is Test { return ((valueAmount * assetUnit) / assetPrice).fromWadDown(); } + function _convertDecimals( + uint256 amount, + uint256 fromDecimals, + uint256 toDecimals, + bool roundUp + ) internal pure returns (uint256) { + return + Math.mulDiv( + amount, + 10 ** toDecimals, + 10 ** fromDecimals, + (roundUp) ? Math.Rounding.Ceil : Math.Rounding.Floor + ); + } + /** * @notice Returns the required debt amount to ensure user position is ~ a certain health factor. * @param desiredHf The desired health factor to be at. @@ -2049,7 +2042,7 @@ abstract contract Base is Test { address user, uint256 reserveId, uint256 desiredHf - ) internal view returns (uint256 requiredDebtAmount) { + ) internal returns (uint256 requiredDebtAmount) { uint256 requiredDebtAmountValue = _getRequiredDebtValueForHf(spoke, user, desiredHf); return _convertValueToAmount(spoke, reserveId, requiredDebtAmountValue); } @@ -2061,13 +2054,36 @@ abstract contract Base is Test { ISpoke spoke, address user, uint256 desiredHf - ) internal view returns (uint256 requiredDebtValue) { + ) internal returns (uint256 requiredDebtValue) { + ISpoke.UserAccountData memory userAccountData = _getUserAccountData(spoke, user, true); + uint256 totalAdjustedCollateralValue = userAccountData.totalCollateralValue.wadMulDown( + userAccountData.avgCollateralFactor + ); + uint256 targetTotalDebtValue = totalAdjustedCollateralValue.wadDivUp(desiredHf); + require( + userAccountData.totalDebtValueRay.fromRayUp() < targetTotalDebtValue, + 'User has enough debt' + ); + return targetTotalDebtValue - userAccountData.totalDebtValueRay.fromRayUp(); + } + + // Helper function to get user account data with potential dynamic config refresh + function _getUserAccountData( + ISpoke spoke, + address user, + bool refreshConfig + ) internal returns (ISpoke.UserAccountData memory) { + uint256 snapshot = vm.snapshotState(); + + if (refreshConfig) { + vm.prank(user); + spoke.updateUserDynamicConfig(user); + } ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); - requiredDebtValue = - userAccountData.totalCollateralValue.wadMulUp(userAccountData.avgCollateralFactor).wadDivUp( - desiredHf - ) - userAccountData.totalDebtValue; + vm.revertToState(snapshot); + + return userAccountData; } function _getUserHealthFactor(ISpoke spoke, address user) internal view returns (uint256) { @@ -2238,6 +2254,14 @@ abstract contract Base is Test { return premiumShares * drawnIndex; } + function _calculateDebtAssetsToRestore( + uint256 drawnSharesToLiquidate, + uint256 premiumDebtRayToLiquidate, + uint256 drawnIndex + ) internal pure returns (uint256) { + return drawnSharesToLiquidate.rayMulUp(drawnIndex) + premiumDebtRayToLiquidate.fromRayUp(); + } + function _calculatePremiumAssetsRay( IHub hub, uint256 assetId, @@ -2310,6 +2334,15 @@ abstract contract Base is Test { return _getLatestDynamicReserveConfig(spoke, reserveId(spoke)).collateralFactor; } + function _getLiquidationFee( + ISpoke spoke, + uint256 reserveId, + address user + ) internal view returns (uint16) { + uint24 dynamicConfigKey = spoke.getUserPosition(reserveId, user).dynamicConfigKey; + return spoke.getDynamicReserveConfig(reserveId, dynamicConfigKey).liquidationFee; + } + function _hasRole( IAccessManager authority, uint64 role, @@ -2343,6 +2376,10 @@ abstract contract Base is Test { } } + function _reserveDrawnIndex(ISpoke spoke, uint256 reserveId) internal view returns (uint256) { + return _hub(spoke, reserveId).getAssetDrawnIndex(_reserveAssetId(spoke, reserveId)); + } + function _deploySpokeWithOracle( address proxyAdminOwner, address _accessManager, @@ -2463,6 +2500,13 @@ abstract contract Base is Test { indexDelta.rayMulUp(initialDrawnShares + initialPremiumShares).percentMulDown(liquidityFee); } + function _calculateMaxSupplyAmount( + ISpoke spoke, + uint256 reserveId + ) internal view returns (uint256) { + return MAX_SUPPLY_ASSET_UNITS * 10 ** spoke.getReserve(reserveId).decimals; + } + /// @dev Get the liquidation bonus for a given reserve at a user HF function _getLiquidationBonus( ISpoke spoke, @@ -2489,7 +2533,7 @@ abstract contract Base is Test { .totalCollateralValue .percentMulDown(userAccountData.avgCollateralFactor.fromWadDown()) .percentMulDown(99_00) - .wadDivDown(desiredHf) - userAccountData.totalDebtValue; + .wadDivDown(desiredHf) - userAccountData.totalDebtValueRay.fromRayUp(); // buffer to force debt lower (ie making sure resultant debt creates HF that is gt desired) } @@ -2522,6 +2566,53 @@ abstract contract Base is Test { ); } + // @dev Requires no previously added assets + // @dev Update _assetsSlot below if it changes + // Run: forge inspect Hub storage-layout + // @dev Update _addedSharesOffset below if it changes + // Have a look at IHub.Asset struct + function _mockSupplySharePrice( + IHub hub, + uint256 assetId, + uint256 totalAddedAssets, + uint256 addedShares + ) internal { + if (!hub.isSpokeListed(assetId, address(spoke1))) { + vm.prank(ADMIN); + hub.addSpoke( + assetId, + address(spoke1), + IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK + }) + ); + } + Utils.add({ + hub: hub, + assetId: assetId, + caller: address(spoke1), + amount: totalAddedAssets, + user: alice + }); + assertEq(hub.getAddedAssets(assetId), totalAddedAssets, '_mockSupplySharePrice: addedAssets'); + + uint256 _assetsSlot = 2; + uint256 _addedSharesOffset = 1; + vm.store( + address(hub), + bytes32( + uint256(SlotDerivation.deriveMapping({slot: bytes32(_assetsSlot), key: assetId})) + + _addedSharesOffset + ), + bytes32(addedShares) + ); + assertEq(hub.getAddedShares(assetId), addedShares, '_mockSupplySharePrice: addedShares'); + } + function _mockInterestRateBps(uint256 interestRateBps) internal { _mockInterestRateBps(address(irStrategy), interestRateBps); } diff --git a/tests/Constants.sol b/tests/Constants.sol index 1d3ae5b46..3df43d3e1 100644 --- a/tests/Constants.sol +++ b/tests/Constants.sol @@ -8,6 +8,8 @@ library Constants { uint8 public constant MIN_ALLOWED_UNDERLYING_DECIMALS = 6; uint40 public constant MAX_ALLOWED_SPOKE_CAP = type(uint40).max; uint24 public constant MAX_RISK_PREMIUM_THRESHOLD = type(uint24).max; // 167772.15% + uint256 public constant VIRTUAL_ASSETS = 1e6; + uint256 public constant VIRTUAL_SHARES = 1e6; /// @dev Spoke Constants uint8 public constant ORACLE_DECIMALS = 8; diff --git a/tests/misc/debt_to_liquidate.py b/tests/misc/debt_to_liquidate.py deleted file mode 100644 index 2651d68bf..000000000 --- a/tests/misc/debt_to_liquidate.py +++ /dev/null @@ -1,62 +0,0 @@ -# Highlights the fact that debtToLiquidate cannot exceed debtReserveBalance in liquidation logic. -from z3 import * - -WAD = IntVal(10**18) -PERCENTAGE_FACTOR = IntVal(10**4) - -DUST_LIQUIDATION_THRESHOLD = IntVal(1000 * 10**26) - -def mulDivDown(a, num, den): - return (a * num) / den - -def mulDivUp(a, num, den): - return (a * num + den - 1) / den - -s = Solver() - -debtAssetPrice = Int('debtAssetPrice') -s.add(1 <= debtAssetPrice, debtAssetPrice <= 10**30) -debtAssetDecimals = Int('debtAssetDecimals') -s.add(1 <= debtAssetDecimals, debtAssetDecimals <= 18) -debtAssetUnit = ToInt(10**debtAssetDecimals) - -collateralAssetPrice = Int('collateralAssetPrice') -s.add(1 <= collateralAssetPrice, collateralAssetPrice <= 10**30) -collateralAssetDecimals = Int('collateralAssetDecimals') -s.add(1 <= collateralAssetDecimals, collateralAssetDecimals <= 18) -collateralAssetUnit = ToInt(10**collateralAssetDecimals) - -liquidationBonus = Int('liquidationBonus') -s.add(PERCENTAGE_FACTOR <= liquidationBonus, liquidationBonus < PERCENTAGE_FACTOR * PERCENTAGE_FACTOR) - -debtReserveBalance = Int('debtReserveBalance') -s.add(0 <= debtReserveBalance, debtReserveBalance <= 10**30) -debtToLiquidate = Int('debtToLiquidate') -s.add(0 <= debtToLiquidate, debtToLiquidate <= debtReserveBalance) - -collateralReserveBalance = Int('collateralReserveBalance') -s.add(0 <= collateralReserveBalance, collateralReserveBalance <= 10**30) -collateralToLiquidate = Int('collateralToLiquidate') -s.add(collateralToLiquidate == mulDivDown(debtToLiquidate, debtAssetPrice * collateralAssetUnit * liquidationBonus, debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR)) - -s.add( - Or( - collateralToLiquidate > collateralReserveBalance, - And( - mulDivDown(collateralReserveBalance - collateralToLiquidate, collateralAssetPrice * WAD, collateralAssetUnit) < DUST_LIQUIDATION_THRESHOLD, - DUST_LIQUIDATION_THRESHOLD <= mulDivDown(debtReserveBalance - debtToLiquidate, debtAssetPrice * WAD, debtAssetUnit) - ) - ) -) - -s.add( - Not( - mulDivUp( - collateralReserveBalance, - collateralAssetPrice * debtAssetUnit * PERCENTAGE_FACTOR, - debtAssetPrice * collateralAssetUnit * liquidationBonus - ) <= debtReserveBalance - ) -) - -print(s.model() if s.check() == sat else 'no counterexample') diff --git a/tests/misc/z3/commons.py b/tests/misc/z3/commons.py new file mode 100644 index 000000000..641e98488 --- /dev/null +++ b/tests/misc/z3/commons.py @@ -0,0 +1,138 @@ +from z3 import * + +WAD = IntVal(10**18) +RAY = IntVal(10**27) +PERCENTAGE_FACTOR = IntVal(10**4) + +VIRTUAL_SHARES = IntVal(10**6) +VIRTUAL_ASSETS = IntVal(10**6) + +MAX_PRICE = IntVal(10**16) +MAX_SUPPLY_AMOUNT = IntVal(10**30) + +MIN_DECIMALS = IntVal(6) +MAX_DECIMALS = IntVal(18) + +MIN_DRAWN_INDEX = RAY +MAX_DRAWN_INDEX = 100 * RAY +MAX_SUPPLY_PRICE = IntVal(100) + +MIN_LIQUIDATION_BONUS = PERCENTAGE_FACTOR +MAX_LIQUIDATION_BONUS = PERCENTAGE_FACTOR * PERCENTAGE_FACTOR - 1 +DUST_LIQUIDATION_THRESHOLD = IntVal(1000 * 10**26) + + +def mulDivDown(a, num, den): + return (a * num) / den + + +def mulDivUp(a, num, den): + return (a * num + den - 1) / den + + +def divUp(a, b): + return (a + b - 1) / b + + +def rayMulUp(a, b): + return (a * b + RAY - 1) / RAY + + +def rayMulDown(a, b): + return (a * b) / RAY + + +def fromRayDown(a): + return a / RAY + + +def fromRayUp(a): + return (a + RAY - 1) / RAY + + +def toRay(a): + return a * RAY + + +def toAddedSharesDown(assets, totalAddedAssets, addedShares): + return mulDivDown( + assets, addedShares + VIRTUAL_SHARES, totalAddedAssets + VIRTUAL_ASSETS + ) + + +def toAddedAssetsDown(shares, totalAddedAssets, addedShares): + return mulDivDown( + shares, totalAddedAssets + VIRTUAL_ASSETS, addedShares + VIRTUAL_SHARES + ) + + +def toAddedSharesUp(assets, totalAddedAssets, addedShares): + return mulDivUp( + assets, addedShares + VIRTUAL_SHARES, totalAddedAssets + VIRTUAL_ASSETS + ) + + +def toAddedAssetsUp(shares, totalAddedAssets, addedShares): + return mulDivUp( + shares, totalAddedAssets + VIRTUAL_ASSETS, addedShares + VIRTUAL_SHARES + ) + + +def previewAddByAssets(assets, totalAddedAssets, addedShares): + return toAddedSharesDown(assets, totalAddedAssets, addedShares) + + +def previewAddByShares(shares, totalAddedAssets, addedShares): + return toAddedAssetsUp(shares, totalAddedAssets, addedShares) + + +def previewRemoveByAssets(assets, totalAddedAssets, addedShares): + return toAddedSharesUp(assets, totalAddedAssets, addedShares) + + +def previewRemoveByShares(shares, totalAddedAssets, addedShares): + return toAddedAssetsDown(shares, totalAddedAssets, addedShares) + + +# Assumes the asset uses at most 18 decimals. +def toValue(amount, decimals, price): + return amount * (10 ** (18 - decimals)) * price + + +def proveValid(s, propertyDescription, property, assumptions=[], variables=[]): + propertyDescriptionOutput = f"-- VALID Property: {propertyDescription} --" + print("=" * len(propertyDescriptionOutput)) + print(propertyDescriptionOutput) + + result = s.check(Not(property), *assumptions) + if result == sat: + print("❌ Property is not valid:") + print(s.model()) + for variable, variableName in variables: + print(f"{variableName}: {s.model().eval(variable)}") + elif result == unsat: + print(f"✅ Property is valid.") + elif result == unknown: + print("❓ Timed out or unknown.") + + print("=" * len(propertyDescriptionOutput)) + + +def proveSatisfiable(s, propertyDescription, property, assumptions=[], variables=[]): + propertyDescriptionOutput = f"-- SATISFIABLE Property: {propertyDescription} --" + print("=" * len(propertyDescriptionOutput)) + print(propertyDescriptionOutput) + + result = s.check(property, *assumptions) + if result == sat: + print("✅ Property is satisfiable") + m = s.model() + print(m) + for variable, variableName in variables: + print(f"{variableName}: {m.eval(variable)}") + elif result == unsat: + print("❌ Property is unsatisfiable.") + elif result == unknown: + print("❓ Timed out or unknown.") + + print("=" * len(propertyDescriptionOutput)) diff --git a/tests/misc/z3/liquidation_logic.py b/tests/misc/z3/liquidation_logic.py new file mode 100644 index 000000000..94dda5e03 --- /dev/null +++ b/tests/misc/z3/liquidation_logic.py @@ -0,0 +1,143 @@ +# Highlights the fact that debtToLiquidate cannot exceed debtReserveBalance in liquidation logic. +from commons import * + +s = Solver() + +# Pricing of collateral asset +addedShares = Int("addedShares") +s.add(0 <= addedShares, addedShares <= MAX_SUPPLY_AMOUNT) +totalAddedAssets = Int("totalAddedAssets") +s.add( + (addedShares + VIRTUAL_SHARES) <= (totalAddedAssets + VIRTUAL_ASSETS), + (totalAddedAssets + VIRTUAL_ASSETS) + <= MAX_SUPPLY_PRICE * (addedShares + VIRTUAL_SHARES), +) +collateralAssetPrice = Int("collateralAssetPrice") +s.add(1 <= collateralAssetPrice, collateralAssetPrice <= MAX_PRICE) +collateralAssetDecimals = Int("collateralAssetDecimals") +s.add(MIN_DECIMALS <= collateralAssetDecimals, collateralAssetDecimals <= MAX_DECIMALS) +collateralAssetUnit = ToInt(10**collateralAssetDecimals) + +# Pricing of debt asset +drawnIndex = Int("drawnIndex") +s.add(MIN_DRAWN_INDEX <= drawnIndex, drawnIndex <= MAX_DRAWN_INDEX) +debtAssetPrice = Int("debtAssetPrice") +s.add(1 <= debtAssetPrice, debtAssetPrice <= MAX_PRICE) +debtAssetDecimals = Int("debtAssetDecimals") +s.add(MIN_DECIMALS <= debtAssetDecimals, debtAssetDecimals <= MAX_DECIMALS) +debtAssetUnit = ToInt(10**debtAssetDecimals) + +# Liquidatable user position +suppliedShares = Int("suppliedShares") +s.add(1 <= suppliedShares, suppliedShares <= addedShares) +drawnShares = Int("drawnShares") +s.add(1 <= drawnShares, drawnShares <= MAX_SUPPLY_AMOUNT) +premiumDebtRay = Int("premiumDebtRay") +s.add(0 <= premiumDebtRay, premiumDebtRay <= MAX_SUPPLY_AMOUNT) + +# Liquidation parameters +liquidationBonus = Int("liquidationBonus") +s.add( + MIN_LIQUIDATION_BONUS <= liquidationBonus, + liquidationBonus <= MAX_LIQUIDATION_BONUS, +) +premiumDebtRayToLiquidate = Int("premiumDebtRayToLiquidate") +s.add(0 <= premiumDebtRayToLiquidate, premiumDebtRayToLiquidate <= premiumDebtRay) +rawDrawnSharesToLiquidate = Int("rawDrawnSharesToLiquidate") +s.add(0 <= rawDrawnSharesToLiquidate, rawDrawnSharesToLiquidate <= drawnShares) +s.add(Or(rawDrawnSharesToLiquidate == 0, premiumDebtRayToLiquidate == premiumDebtRay)) + +# Enforce debt dust condition +debtRayRemaining = ( + (drawnShares - rawDrawnSharesToLiquidate) * drawnIndex + + premiumDebtRay + - premiumDebtRayToLiquidate +) +leavesDebtDust = And( + rawDrawnSharesToLiquidate < drawnShares, + toValue( + fromRayDown(debtRayRemaining), + debtAssetDecimals, + debtAssetPrice, + ) + < DUST_LIQUIDATION_THRESHOLD, +) +drawnSharesToLiquidate = Int("drawnSharesToLiquidate") +s.add( + Or( + And(Not(leavesDebtDust), drawnSharesToLiquidate == rawDrawnSharesToLiquidate), + And(leavesDebtDust, drawnSharesToLiquidate == drawnShares, premiumDebtRayToLiquidate == premiumDebtRay), + ) +) + +# Calculate collateral shares to liquidate +collateralSharesToLiquidate = previewAddByAssets( + mulDivDown( + drawnSharesToLiquidate * drawnIndex + premiumDebtRayToLiquidate, + debtAssetPrice * collateralAssetUnit * liquidationBonus, + debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR * RAY, + ), + totalAddedAssets, + addedShares, +) + +# Enforce recalculation of debt to liquidate +leavesCollateralDust = And( + collateralSharesToLiquidate < suppliedShares, + toValue( + previewRemoveByShares( + suppliedShares - collateralSharesToLiquidate, + totalAddedAssets, + addedShares, + ), + collateralAssetDecimals, + collateralAssetPrice, + ) + < DUST_LIQUIDATION_THRESHOLD, +) +s.add( + Or( + collateralSharesToLiquidate > suppliedShares, + And( + leavesCollateralDust, + drawnSharesToLiquidate < drawnShares, + ), + ), +) + +# Recalculate debt to liquidate +debtRayToLiquidate = mulDivUp( + previewAddByShares(suppliedShares, totalAddedAssets, addedShares), + collateralAssetPrice * debtAssetUnit * PERCENTAGE_FACTOR * RAY, + debtAssetPrice * collateralAssetUnit * liquidationBonus, +) + +# Enforce premium debt is fully liquidated +s.add(premiumDebtRay < debtRayToLiquidate) +recalculatedDrawnSharesToLiquidate = divUp( + debtRayToLiquidate - premiumDebtRay, drawnIndex +) + +proveSatisfiable( + s, + "Recalculated drawnSharesToLiquidate can exceed user's drawn shares", + recalculatedDrawnSharesToLiquidate > drawnShares, +) + +# Enforce recalculation of collateralSharesToLiquidate +s.add(recalculatedDrawnSharesToLiquidate > drawnShares) +recalculatedCollateralSharesToLiquidate = previewAddByAssets( + mulDivDown( + drawnShares * drawnIndex + premiumDebtRay, + debtAssetPrice * collateralAssetUnit * liquidationBonus, + debtAssetUnit * collateralAssetPrice * PERCENTAGE_FACTOR * RAY, + ), + totalAddedAssets, + addedShares, +) + +proveSatisfiable( + s, + "Recalculated collateralSharesToLiquidate can exceed user's supplied shares", + recalculatedCollateralSharesToLiquidate > suppliedShares, +) diff --git a/tests/mocks/LiquidationLogicWrapper.sol b/tests/mocks/LiquidationLogicWrapper.sol index 3a61f005a..141b887dc 100644 --- a/tests/mocks/LiquidationLogicWrapper.sol +++ b/tests/mocks/LiquidationLogicWrapper.sol @@ -17,18 +17,17 @@ contract LiquidationLogicWrapper { using PositionStatusMap for ISpoke.PositionStatus; using ReserveFlagsMap for ReserveFlags; + mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; mapping(address user => mapping(uint256 reserveId => ISpoke.UserPosition)) internal _userPositions; - mapping(uint256 reserveId => ISpoke.Reserve) internal _reserves; mapping(address user => ISpoke.PositionStatus) internal _positionStatuses; + mapping(uint256 reserveId => mapping(uint24 dynamicConfigKey => ISpoke.DynamicReserveConfig)) + internal _dynamicConfig; address internal _borrower; address internal _liquidator; uint256 internal _collateralReserveId; uint256 internal _debtReserveId; - ISpoke.LiquidationConfig internal liquidationConfig; - ISpoke.DynamicReserveConfig internal dynamicCollateralConfig; - constructor(address borrower_, address liquidator_) { _borrower = borrower_; _liquidator = liquidator_; @@ -42,6 +41,10 @@ contract LiquidationLogicWrapper { _liquidator = liquidator; } + function setCollateralReserveId(uint256 reserveId) public { + _collateralReserveId = reserveId; + } + function setCollateralReserveHub(IHub hub) public { _reserves[_collateralReserveId].hub = hub; } @@ -54,24 +57,31 @@ contract LiquidationLogicWrapper { _reserves[_collateralReserveId].assetId = assetId.toUint16(); } - function setCollateralReserveId(uint256 reserveId) public { - _collateralReserveId = reserveId; + function setCollateralReserveFlags(ReserveFlags flags) public { + _reserves[_collateralReserveId].flags = flags; + } + + function setDynamicCollateralConfig( + ISpoke.DynamicReserveConfig memory newDynamicCollateralConfig + ) public { + uint24 dynamicConfigKey = _userPositions[_borrower][_collateralReserveId].dynamicConfigKey; + _dynamicConfig[_collateralReserveId][dynamicConfigKey] = newDynamicCollateralConfig; } function setCollateralPositionSuppliedShares(uint256 suppliedShares) public { _userPositions[_borrower][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); } - function setLiquidatorPositionSuppliedShares(address liquidator, uint256 suppliedShares) public { - _userPositions[liquidator][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); + function setCollateralPositionDynamicConfigKey(uint256 dynamicConfigKey) public { + _userPositions[_borrower][_collateralReserveId].dynamicConfigKey = dynamicConfigKey.toUint24(); } - function getCollateralReserve() public view returns (ISpoke.Reserve memory) { - return _reserves[_collateralReserveId]; + function setLiquidatorPositionSuppliedShares(address liquidator, uint256 suppliedShares) public { + _userPositions[liquidator][_collateralReserveId].suppliedShares = suppliedShares.toUint120(); } - function getCollateralPosition(address user) public view returns (ISpoke.UserPosition memory) { - return _userPositions[user][_collateralReserveId]; + function setDebtReserveId(uint256 reserveId) public { + _debtReserveId = reserveId; } function setDebtReserveHub(IHub hub) public { @@ -86,14 +96,14 @@ contract LiquidationLogicWrapper { _reserves[_debtReserveId].assetId = assetId.toUint16(); } - function setDebtReserveId(uint256 reserveId) public { - _debtReserveId = reserveId; - } - function setDebtReserveUnderlying(address underlying) public { _reserves[_debtReserveId].underlying = underlying; } + function setDebtReserveFlags(ReserveFlags flags) public { + _reserves[_debtReserveId].flags = flags; + } + function setDebtPositionDrawnShares(uint256 drawnShares) public { _userPositions[_borrower][_debtReserveId].drawnShares = drawnShares.toUint120(); } @@ -122,6 +132,61 @@ contract LiquidationLogicWrapper { _positionStatuses[_liquidator].setBorrowing(reserveId, status); } + function liquidateCollateral( + LiquidationLogic.LiquidateCollateralParams memory params + ) public returns (LiquidationLogic.LiquidateCollateralResult memory) { + return + LiquidationLogic._liquidateCollateral( + _userPositions[_borrower][_collateralReserveId], + _userPositions[_liquidator][_collateralReserveId], + params + ); + } + + function liquidateDebt( + LiquidationLogic.LiquidateDebtParams memory params + ) public returns (LiquidationLogic.LiquidateDebtResult memory) { + return + LiquidationLogic._liquidateDebt( + _userPositions[_borrower][_debtReserveId], + _positionStatuses[_borrower], + params + ); + } + + function executeLiquidation( + LiquidationLogic.ExecuteLiquidationParams memory params + ) public returns (bool) { + return + LiquidationLogic._executeLiquidation( + _userPositions[_borrower][_collateralReserveId], + _userPositions[_borrower][_debtReserveId], + _userPositions[_liquidator][_collateralReserveId], + _positionStatuses[_borrower], + params + ); + } + + function liquidateUser(LiquidationLogic.LiquidateUserParams memory params) public returns (bool) { + return + LiquidationLogic.liquidateUser( + _reserves[_collateralReserveId], + _reserves[_debtReserveId], + _userPositions, + _positionStatuses, + _dynamicConfig, + params + ); + } + + function getCollateralReserve() public view returns (ISpoke.Reserve memory) { + return _reserves[_collateralReserveId]; + } + + function getCollateralPosition(address user) public view returns (ISpoke.UserPosition memory) { + return _userPositions[user][_collateralReserveId]; + } + function getDebtReserve() public view returns (ISpoke.Reserve memory) { return _reserves[_debtReserveId]; } @@ -146,14 +211,10 @@ contract LiquidationLogicWrapper { return _positionStatuses[_liquidator].isBorrowing(reserveId); } - function setLiquidationConfig(ISpoke.LiquidationConfig memory newLiquidationConfig) public { - liquidationConfig = newLiquidationConfig; - } - - function setDynamicCollateralConfig( - ISpoke.DynamicReserveConfig memory newDynamicCollateralConfig - ) public { - dynamicCollateralConfig = newDynamicCollateralConfig; + function calculateLiquidationAmounts( + LiquidationLogic.CalculateLiquidationAmountsParams memory params + ) public view returns (LiquidationLogic.LiquidationAmounts memory) { + return LiquidationLogic._calculateLiquidationAmounts(params); } function calculateLiquidationBonus( @@ -185,14 +246,14 @@ contract LiquidationLogicWrapper { function calculateDebtToLiquidate( LiquidationLogic.CalculateDebtToLiquidateParams memory params - ) public pure returns (uint256) { + ) public pure returns (uint256, uint256) { return LiquidationLogic._calculateDebtToLiquidate(params); } - function calculateLiquidationAmounts( - LiquidationLogic.CalculateLiquidationAmountsParams memory params - ) public pure returns (LiquidationLogic.LiquidationAmounts memory) { - return LiquidationLogic._calculateLiquidationAmounts(params); + function calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) public view returns (uint256) { + return LiquidationLogic._calculateCollateralToLiquidate(params); } function evaluateDeficit( @@ -209,41 +270,4 @@ contract LiquidationLogicWrapper { borrowedCount ); } - - function liquidateCollateral( - LiquidationLogic.LiquidateCollateralParams memory params - ) public returns (uint256, uint256, bool) { - return - LiquidationLogic._liquidateCollateral( - _reserves[_collateralReserveId], - _userPositions[_borrower][_collateralReserveId], - _userPositions[_liquidator][_collateralReserveId], - params - ); - } - - function liquidateDebt( - LiquidationLogic.LiquidateDebtParams memory params - ) public returns (uint256, IHubBase.PremiumDelta memory, bool) { - return - LiquidationLogic._liquidateDebt( - _reserves[_debtReserveId], - _userPositions[_borrower][_debtReserveId], - _positionStatuses[_borrower], - params - ); - } - - function liquidateUser(LiquidationLogic.LiquidateUserParams memory params) public returns (bool) { - return - LiquidationLogic.liquidateUser( - _reserves[_collateralReserveId], - _reserves[_debtReserveId], - _userPositions, - _positionStatuses, - liquidationConfig, - dynamicCollateralConfig, - params - ); - } } diff --git a/tests/mocks/MockSpoke.sol b/tests/mocks/MockSpoke.sol index cffe2bf3f..b25119154 100644 --- a/tests/mocks/MockSpoke.sol +++ b/tests/mocks/MockSpoke.sol @@ -33,22 +33,26 @@ contract MockSpoke is Spoke, Test { uint256 reserveId, uint256 amount, address onBehalfOf - ) external onlyPositionManager(onBehalfOf) { - Reserve storage reserve = _reserves[reserveId]; + ) external nonReentrant onlyPositionManager(onBehalfOf) returns (uint256, uint256) { + Reserve storage reserve = _getReserve(reserveId); UserPosition storage userPosition = _userPositions[onBehalfOf][reserveId]; PositionStatus storage positionStatus = _positionStatus[onBehalfOf]; - uint256 assetId = reserve.assetId; + _validateBorrow(reserve.flags); IHubBase hub = reserve.hub; - uint256 drawnShares = hub.draw(assetId, amount, msg.sender); - + uint256 drawnShares = hub.draw(reserve.assetId, amount, msg.sender); userPosition.drawnShares += drawnShares.toUint120(); - positionStatus.setBorrowing(reserveId, true); + if (!positionStatus.isBorrowing(reserveId)) { + positionStatus.setBorrowing(reserveId, true); + } - ISpoke.UserAccountData memory userAccountData = _processUserAccountData(onBehalfOf, true); - _notifyRiskPremiumUpdate(onBehalfOf, userAccountData.riskPremium); + uint256 newRiskPremium = _processUserAccountData(onBehalfOf, true).riskPremium; + emit RefreshAllUserDynamicConfig(onBehalfOf); + _notifyRiskPremiumUpdate(onBehalfOf, newRiskPremium); emit Borrow(reserveId, msg.sender, onBehalfOf, drawnShares, amount); + + return (drawnShares, amount); } // Mock the user account data diff --git a/tests/mocks/WadRayMathWrapper.sol b/tests/mocks/WadRayMathWrapper.sol index 955e2d883..ef5df55ba 100644 --- a/tests/mocks/WadRayMathWrapper.sol +++ b/tests/mocks/WadRayMathWrapper.sol @@ -5,6 +5,10 @@ pragma solidity ^0.8.0; import {WadRayMath} from 'src/libraries/math/WadRayMath.sol'; contract WadRayMathWrapper { + function WAD_DECIMALS() public pure returns (uint256) { + return WadRayMath.WAD_DECIMALS; + } + function WAD() public pure returns (uint256) { return WadRayMath.WAD; } @@ -61,6 +65,10 @@ contract WadRayMathWrapper { return WadRayMath.fromWadDown(a); } + function fromRayDown(uint256 a) public pure returns (uint256) { + return WadRayMath.fromRayDown(a); + } + function fromRayUp(uint256 a) public pure returns (uint256) { return WadRayMath.fromRayUp(a); } diff --git a/tests/unit/Hub/Hub.RefreshPremium.t.sol b/tests/unit/Hub/Hub.RefreshPremium.t.sol index 5b7ca6c58..d195c9354 100644 --- a/tests/unit/Hub/Hub.RefreshPremium.t.sol +++ b/tests/unit/Hub/Hub.RefreshPremium.t.sol @@ -24,13 +24,13 @@ contract HubRefreshPremiumTest is HubBase { } function _createDrawnSharesAndPremiumData() internal { - Utils.supplyCollateral(spoke1, _wbtcReserveId(spoke1), bob, MAX_SUPPLY_AMOUNT, bob); + Utils.supplyCollateral(spoke1, _wbtcReserveId(spoke1), bob, MAX_SUPPLY_AMOUNT_WBTC, bob); - uint256 amount1 = vm.randomUint(1, MAX_SUPPLY_AMOUNT / 2); - uint256 amount2 = vm.randomUint(1, MAX_SUPPLY_AMOUNT - amount1); + uint256 amount1 = vm.randomUint(1, MAX_SUPPLY_AMOUNT_DAI / 2); + uint256 amount2 = vm.randomUint(1, MAX_SUPPLY_AMOUNT_DAI - amount1); // create drawn shares and premium data - _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT); + _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT_DAI); Utils.borrow(spoke1, _daiReserveId(spoke1), bob, amount1, bob); skip(322 days); Utils.borrow(spoke1, _daiReserveId(spoke1), bob, amount2, bob); diff --git a/tests/unit/Hub/Hub.ReportDeficit.t.sol b/tests/unit/Hub/Hub.ReportDeficit.t.sol index 37a1c5a49..68e039fc9 100644 --- a/tests/unit/Hub/Hub.ReportDeficit.t.sol +++ b/tests/unit/Hub/Hub.ReportDeficit.t.sol @@ -28,9 +28,9 @@ contract HubReportDeficitTest is HubBase { super.setUp(); // deploy borrowable liquidity - _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT); - _addLiquidity(wethAssetId, MAX_SUPPLY_AMOUNT); - _addLiquidity(usdxAssetId, MAX_SUPPLY_AMOUNT); + _addLiquidity(daiAssetId, MAX_SUPPLY_AMOUNT_DAI); + _addLiquidity(wethAssetId, MAX_SUPPLY_AMOUNT_WETH); + _addLiquidity(usdxAssetId, MAX_SUPPLY_AMOUNT_USDX); } function test_reportDeficit_revertsWith_SpokeNotActive(address caller) public { @@ -52,7 +52,7 @@ contract HubReportDeficitTest is HubBase { function test_reportDeficit_fuzz_revertsWith_SurplusDrawnDeficitReported( uint256 drawnAmount ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); // draw usdx liquidity to be restored _drawLiquidity({ @@ -100,7 +100,7 @@ contract HubReportDeficitTest is HubBase { function test_reportDeficit_fuzz_revertsWith_SurplusPremiumRayDeficitReported( uint256 drawnAmount ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); // draw usdx liquidity to be restored _drawLiquidity(usdxAssetId, drawnAmount, true, true, address(spoke1)); @@ -170,7 +170,7 @@ contract HubReportDeficitTest is HubBase { uint256 premiumAmountRay, uint256 skipTime ) public { - drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT); + drawnAmount = bound(drawnAmount, 1, MAX_SUPPLY_AMOUNT_USDX); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); ReportDeficitTestParams memory params; diff --git a/tests/unit/MathUtils.t.sol b/tests/unit/MathUtils.t.sol index c6bff0a78..379539586 100644 --- a/tests/unit/MathUtils.t.sol +++ b/tests/unit/MathUtils.t.sol @@ -161,6 +161,16 @@ contract MathUtilsTest is Base { assertEq(result, expectedRes); } + function test_fuzz_divUp(uint256 a, uint256 b) external { + if (b == 0) { + vm.expectRevert(); + MathUtils.divUp(a, b); + } else { + uint256 result = MathUtils.divUp(a, b); + assertEq(result, a / b + (a % b > 0 ? 1 : 0)); + } + } + function test_mulDivDown_WithRemainder() external pure { assertEq(MathUtils.mulDivDown(2, 13, 3), 8); // 26 / 3 = 8.666 -> floor -> 8 } diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol index b0ddbefd4..b68cf4c95 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol @@ -47,15 +47,17 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { } struct LiquidationMetadata { - uint256 debtToTarget; - uint256 collateralToLiquidate; - uint256 collateralToLiquidator; + uint256 debtRayToTarget; + uint256 collateralAssetsToLiquidate; + uint256 collateralAssetsToLiquidator; uint256 collateralSharesToLiquidate; uint256 collateralSharesToLiquidator; - uint256 debtToLiquidate; + uint256 debtAssetsToLiquidate; + uint256 debtRayToLiquidate; + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + uint256 debtAssetsToRestore; uint256 liquidationBonus; - uint256 expectedUserRiskPremium; - uint256 expectedUserAvgCollateralFactor; bool fullDebtReserveLiquidated; bool isCollateralAffectingUserHf; bool hasDeficit; @@ -63,7 +65,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { struct ExpectEventsAndCallsParams { uint256 userDrawnDebt; uint256 userPremiumDebt; - uint256 baseAmountToRestore; + uint256 drawnAmountToRestore; int256 realizedDelta; IHubBase.PremiumDelta premiumDelta; ISpoke.UserPosition userReservePosition; @@ -74,67 +76,14 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { uint256 collateralAssetId; } - struct ExactLiquidationAmounts { - uint256 collateralSharesToLiquidate; - uint256 collateralSharesToLiquidator; - uint256 drawnSharesToLiquidate; - uint256 premiumDebtRayToLiquidate; - bool fullDebtReserveLiquidated; - } - - /// @notice Bound liquidation config to full range of possible values - function _bound( - ISpoke.LiquidationConfig memory liqConfig - ) internal pure virtual returns (ISpoke.LiquidationConfig memory) { - liqConfig.targetHealthFactor = bound( - liqConfig.targetHealthFactor, - HEALTH_FACTOR_LIQUIDATION_THRESHOLD, - MAX_CLOSE_FACTOR - ).toUint120(); - - liqConfig.healthFactorForMaxBonus = bound( - liqConfig.healthFactorForMaxBonus, - 0, - HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1 - ).toUint64(); - - liqConfig.liquidationBonusFactor = bound( - liqConfig.liquidationBonusFactor, - 0, - PercentageMath.PERCENTAGE_FACTOR - ).toUint16(); - - return liqConfig; - } - function _bound( - ISpoke.DynamicReserveConfig memory dynConfig - ) internal pure virtual returns (ISpoke.DynamicReserveConfig memory) { - dynConfig.maxLiquidationBonus = bound( - dynConfig.maxLiquidationBonus, - MIN_LIQUIDATION_BONUS, - MAX_LIQUIDATION_BONUS - ).toUint32(); - dynConfig.collateralFactor = bound( - dynConfig.collateralFactor, - 1, - (PercentageMath.PERCENTAGE_FACTOR - 1).percentDivDown(dynConfig.maxLiquidationBonus) - ).toUint16(); - return dynConfig; - } - - function _boundAssume( ISpoke spoke, uint256 collateralReserveId, - uint256 debtReserveId, - address user, - address liquidator - ) internal virtual returns (uint256, uint256, address) { + uint256 debtReserveId + ) internal view virtual returns (uint256, uint256) { collateralReserveId = bound(collateralReserveId, 0, spoke.getReserveCount() - 1); debtReserveId = bound(debtReserveId, 0, spoke.getReserveCount() - 1); - vm.assume(user != liquidator); - assumeUnusedAddress(user); - return (collateralReserveId, debtReserveId, user); + return (collateralReserveId, debtReserveId); } function _boundDebtToCoverNoDustRevert( @@ -162,19 +111,24 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { try liquidationLogicWrapper.calculateLiquidationAmounts(params) returns ( LiquidationLogic.LiquidationAmounts memory ) {} catch { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); uint256 liquidationBonus = spoke.getLiquidationBonus( collateralReserveId, user, - userAccountData.healthFactor + spoke.getUserAccountData(user).healthFactor + ); + uint256 debtReserveBalance = params.drawnShares.rayMulUp(params.drawnIndex) + + params.premiumDebtRay.fromRayUp(); + uint256 collateralReserveBalance = params.collateralReserveHub.previewRemoveByShares( + params.collateralReserveAssetId, + params.suppliedShares ); debtToCover = bound( debtToCover, - params.debtReserveBalance.min( + debtReserveBalance.min( _convertAssetAmount( spoke, collateralReserveId, - params.collateralReserveBalance.percentDivUp(liquidationBonus), + collateralReserveBalance.percentDivUp(liquidationBonus), debtReserveId ) ), @@ -187,54 +141,6 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { return debtToCover; } - function _bound( - ISpoke spoke, - uint256[] memory reserveIds, - uint256 reserveIdToExclude, - uint256 maxLength - ) internal view returns (bytes memory) { - uint256[] memory boundedReserveIds = new uint256[](_min(reserveIds.length, maxLength)); - - for (uint256 i = 0; i < boundedReserveIds.length; i++) { - boundedReserveIds[i] = bound(reserveIds[i], 0, spoke.getReserveCount() - 1); - if (boundedReserveIds[i] == reserveIdToExclude) { - boundedReserveIds[i] = bound(boundedReserveIds[i] + 1, 0, spoke.getReserveCount() - 1); - } - } - return abi.encode(boundedReserveIds); - } - - function _getCalculateDebtToLiquidateParams( - ISpoke spoke, - uint256 collateralReserveId, - uint256 debtReserveId, - address user, - uint256 debtToCover - ) internal virtual returns (LiquidationLogic.CalculateDebtToLiquidateParams memory) { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); - return - LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: spoke.getUserTotalDebt(debtReserveId, user), - totalDebtValue: userAccountData.totalDebtValue, - debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), - debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, - debtToCover: debtToCover, - liquidationBonus: spoke.getLiquidationBonus( - collateralReserveId, - user, - userAccountData.healthFactor - ), - collateralFactor: spoke - .getDynamicReserveConfig( - collateralReserveId, - spoke.getUserPosition(collateralReserveId, user).dynamicConfigKey - ) - .collateralFactor, - healthFactor: userAccountData.healthFactor, - targetHealthFactor: spoke.getLiquidationConfig().targetHealthFactor - }); - } - function _getCalculateDebtToTargetHealthFactorParams( ISpoke spoke, uint256 collateralReserveId, @@ -244,7 +150,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: userAccountData.totalDebtValue, + totalDebtValueRay: userAccountData.totalDebtValueRay, debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), collateralFactor: spoke @@ -273,12 +179,16 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); return LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: spoke.getUserSuppliedAssets(collateralReserveId, user), - collateralAssetUnit: 10 ** spoke.getReserve(collateralReserveId).decimals, + collateralReserveHub: _hub(spoke, collateralReserveId), + collateralReserveAssetId: spoke.getReserve(collateralReserveId).assetId, + suppliedShares: spoke.getUserPosition(collateralReserveId, user).suppliedShares, + collateralAssetDecimals: spoke.getReserve(collateralReserveId).decimals, collateralAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(collateralReserveId), - debtReserveBalance: spoke.getUserTotalDebt(debtReserveId, user), - totalDebtValue: userAccountData.totalDebtValue, - debtAssetUnit: 10 ** spoke.getReserve(debtReserveId).decimals, + drawnShares: spoke.getUserPosition(debtReserveId, user).drawnShares, + premiumDebtRay: _calculatePremiumDebtRay(spoke, debtReserveId, user), + drawnIndex: _reserveDrawnIndex(spoke, debtReserveId), + totalDebtValueRay: userAccountData.totalDebtValueRay, + debtAssetDecimals: spoke.getReserve(debtReserveId).decimals, debtAssetPrice: IPriceOracle(spoke.ORACLE()).getReservePrice(debtReserveId), debtToCover: debtToCover, collateralFactor: spoke @@ -322,93 +232,149 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { _borrowToBeAtHf(spoke, user, debtReserveId, newHealthFactor); } - function _calculateExpectedUserRiskPremiumAndAvgCollateralFactor( + // calculate expected user account data after liquidation + function _calculateExpectedUserAccountData( CheckedLiquidationCallParams memory params, - ISpoke.UserAccountData memory userAccountDataBefore, - uint256 collateralToLiquidate, - uint256 debtToLiquidate - ) internal virtual returns (uint256, uint256) { - KeyValueList.List memory list = KeyValueList.init(userAccountDataBefore.activeCollateralCount); - - uint256 totalCollateralValue = 0; - uint256 newAvgCollateralFactor = 0; + LiquidationMetadata memory liquidationMetadata + ) internal virtual returns (ISpoke.UserAccountData memory expectedUserAccountData) { + KeyValueList.List memory list = KeyValueList.init(params.spoke.getReserveCount()); - uint256 index = 0; for (uint256 reserveId = 0; reserveId < params.spoke.getReserveCount(); reserveId++) { if (!_isUsingAsCollateral(params.spoke, reserveId, params.user)) { continue; } - uint256 collateralFactor = _getCollateralFactor(params.spoke, reserveId, params.user); - if (collateralFactor == 0) { + if (_getCollateralFactor(params.spoke, reserveId, params.user) == 0) { continue; } - uint256 userSuppliedAmount = params.spoke.getUserSuppliedAssets(reserveId, params.user); + IHubBase hub = _hub(params.spoke, reserveId); + uint256 assetId = _reserveAssetId(params.spoke, reserveId); + uint256 totalAddedAssets = hub.getAddedAssets(assetId); + uint256 totalAddedShares = hub.getAddedShares(assetId); + uint256 userSuppliedShares = params + .spoke + .getUserPosition(reserveId, params.user) + .suppliedShares; + if (params.collateralReserveId == reserveId) { - userSuppliedAmount -= collateralToLiquidate; + userSuppliedShares -= liquidationMetadata.collateralSharesToLiquidate; + if (!params.receiveShares) { + totalAddedAssets -= liquidationMetadata.collateralAssetsToLiquidator; + totalAddedShares -= liquidationMetadata.collateralSharesToLiquidator; + } + } + + if (userSuppliedShares == 0) { + continue; + } + + if (params.debtReserveId == reserveId) { + IHub.Asset memory asset = IHub(address(hub)).getAsset(assetId); + uint256 drawnIndex = _reserveDrawnIndex(params.spoke, reserveId); + uint256 premiumDebtRay = _calculatePremiumDebtRay( + asset.premiumShares, + asset.premiumOffsetRay, + drawnIndex + ); + totalAddedAssets += liquidationMetadata.debtAssetsToLiquidate; + uint256 aggregatedOwedRayBefore = asset.drawnShares * drawnIndex + + premiumDebtRay + + asset.deficitRay; + totalAddedAssets -= (aggregatedOwedRayBefore.fromRayUp() - + (aggregatedOwedRayBefore - liquidationMetadata.debtRayToLiquidate).fromRayUp()); } - if (userSuppliedAmount == 0) { + + uint256 userSuppliedAssets = userSuppliedShares.mulDivDown( + totalAddedAssets + Constants.VIRTUAL_ASSETS, + totalAddedShares + Constants.VIRTUAL_SHARES + ); + uint256 userSuppliedValue = _convertAmountToValue( + params.spoke, + reserveId, + userSuppliedAssets + ); + list.add( + expectedUserAccountData.activeCollateralCount++, + _getCollateralRisk(params.spoke, reserveId), + userSuppliedValue + ); + expectedUserAccountData.totalCollateralValue += userSuppliedValue; + expectedUserAccountData.avgCollateralFactor += + _getCollateralFactor(params.spoke, reserveId, params.user) * userSuppliedValue; + } + + for ( + uint256 reserveId = 0; + reserveId < params.spoke.getReserveCount() && !liquidationMetadata.hasDeficit; + reserveId++ + ) { + if (!_isBorrowing(params.spoke, reserveId, params.user)) { continue; } - // from now, userSuppliedAmount is in value terms (to avoid stack too deep) - userSuppliedAmount = _convertAmountToValue(params.spoke, reserveId, userSuppliedAmount); - list.add(index++, _getCollateralRisk(params.spoke, reserveId), userSuppliedAmount); - totalCollateralValue += userSuppliedAmount; - newAvgCollateralFactor += collateralFactor * userSuppliedAmount; + uint256 userDrawnShares = params.spoke.getUserPosition(reserveId, params.user).drawnShares; + uint256 userPremiumDebtRay = _calculatePremiumDebtRay(params.spoke, reserveId, params.user); + if (params.debtReserveId == reserveId) { + userDrawnShares -= liquidationMetadata.drawnSharesToLiquidate.toUint120(); + userPremiumDebtRay -= liquidationMetadata.premiumDebtRayToLiquidate; + } + if (userDrawnShares == 0) { + continue; + } + expectedUserAccountData.borrowedCount++; + expectedUserAccountData.totalDebtValueRay += _convertAmountToValue( + params.spoke, + reserveId, + userDrawnShares * _reserveDrawnIndex(params.spoke, reserveId) + userPremiumDebtRay + ); } - if (totalCollateralValue != 0) { - newAvgCollateralFactor = newAvgCollateralFactor - .wadDivDown(totalCollateralValue) - .fromBpsDown(); + if (expectedUserAccountData.totalDebtValueRay > 0) { + expectedUserAccountData.healthFactor = Math.mulDiv( + expectedUserAccountData.avgCollateralFactor, + (WadRayMath.WAD * WadRayMath.RAY) / PercentageMath.PERCENTAGE_FACTOR, + expectedUserAccountData.totalDebtValueRay, + Math.Rounding.Floor + ); + } else { + expectedUserAccountData.healthFactor = type(uint256).max; } - list.sortByKey(); - uint256 debtToLiquidateValue = _convertAmountToValue( - params.spoke, - params.debtReserveId, - debtToLiquidate - ); - uint256 totalDebtToCover = userAccountDataBefore.totalDebtValue - debtToLiquidateValue; - uint256 remainingDebtToCover = totalDebtToCover; + if (expectedUserAccountData.totalCollateralValue != 0) { + expectedUserAccountData.avgCollateralFactor = expectedUserAccountData + .avgCollateralFactor + .mulDivDown( + WadRayMath.WAD / PercentageMath.PERCENTAGE_FACTOR, + expectedUserAccountData.totalCollateralValue + ); + } + list.sortByKey(); - uint256 newRiskPremium = 0; + uint256 remainingDebtToCover = expectedUserAccountData.totalDebtValueRay.fromRayUp(); for (uint256 i = 0; i < list.length() && remainingDebtToCover > 0; i++) { (uint256 collateralRisk, uint256 collateralValue) = list.get(i); - newRiskPremium += collateralRisk * _min(collateralValue, remainingDebtToCover); + expectedUserAccountData.riskPremium += + collateralRisk * _min(collateralValue, remainingDebtToCover); remainingDebtToCover -= _min(collateralValue, remainingDebtToCover); } - newRiskPremium /= _max(1, _min(totalDebtToCover, totalCollateralValue)); - - return (newRiskPremium, newAvgCollateralFactor); - } - - function _calculateExactRestoreAmount( - IHub hub, - uint256 drawn, - uint256 premium, - uint256 restoreAmount, - uint256 assetId - ) internal view returns (uint256, uint256) { - if (restoreAmount <= premium) { - return (0, restoreAmount); - } - uint256 drawnRestored = _min(drawn, restoreAmount - premium); - // round drawn debt to nearest whole share - drawnRestored = hub.previewRestoreByShares( - assetId, - hub.previewRestoreByAssets(assetId, drawnRestored) + expectedUserAccountData.riskPremium /= _max( + 1, + _min( + expectedUserAccountData.totalDebtValueRay.fromRayUp(), + expectedUserAccountData.totalCollateralValue + ) ); - return (drawnRestored, premium); + + return expectedUserAccountData; } function _expectEventsAndCalls( CheckedLiquidationCallParams memory params, - AccountsInfo memory /*accountsInfoBefore*/, - LiquidationMetadata memory liquidationMetadata + AccountsInfo memory accountsInfoBefore, + LiquidationMetadata memory liquidationMetadata, + ISpoke.UserAccountData memory expectedUserAccountData ) internal virtual { ExpectEventsAndCallsParams memory vars; @@ -423,19 +389,33 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params.user ); - (vars.baseAmountToRestore, ) = _calculateRestoreAmounts( - params.spoke, - params.debtReserveId, - params.user, - liquidationMetadata.debtToLiquidate + vars.drawnAmountToRestore = vars.debtHub.previewRestoreByShares( + vars.debtAssetId, + liquidationMetadata.drawnSharesToLiquidate ); + uint256 amountToRestore = vars.drawnAmountToRestore + + liquidationMetadata.premiumDebtRayToLiquidate.fromRayUp(); vars.premiumDelta = _getExpectedPremiumDeltaForRestore( params.spoke, params.user, params.debtReserveId, - liquidationMetadata.debtToLiquidate + amountToRestore ); + if ( + liquidationMetadata.collateralSharesToLiquidate > + liquidationMetadata.collateralSharesToLiquidator + ) { + vm.expectEmit(address(_hub(params.spoke, params.collateralReserveId))); + emit IHubBase.TransferShares({ + assetId: _reserveAssetId(params.spoke, params.collateralReserveId), + sender: address(params.spoke), + receiver: _getFeeReceiver(params.spoke, params.collateralReserveId), + shares: liquidationMetadata.collateralSharesToLiquidate - + liquidationMetadata.collateralSharesToLiquidator + }); + } + vm.expectEmit(address(params.spoke)); emit ISpokeBase.LiquidationCall({ collateralReserveId: params.collateralReserveId, @@ -443,44 +423,57 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { user: params.user, liquidator: params.liquidator, receiveShares: params.receiveShares, - debtToLiquidate: liquidationMetadata.debtToLiquidate, - drawnSharesToLiquidate: vars - .debtHub - .previewRestoreByAssets(vars.debtAssetId, vars.baseAmountToRestore) - .toUint120(), + debtAmountRestored: amountToRestore, + drawnSharesLiquidated: liquidationMetadata.drawnSharesToLiquidate, premiumDelta: vars.premiumDelta, - collateralToLiquidate: liquidationMetadata.collateralToLiquidate, - collateralSharesToLiquidate: liquidationMetadata.collateralSharesToLiquidate, + collateralAmountRemoved: vars.collateralHub.previewRemoveByShares( + vars.collateralAssetId, + liquidationMetadata.collateralSharesToLiquidate + ), + collateralSharesLiquidated: liquidationMetadata.collateralSharesToLiquidate, collateralSharesToLiquidator: liquidationMetadata.collateralSharesToLiquidator }); - if (!params.receiveShares && liquidationMetadata.collateralToLiquidator > 0) { - vm.expectCall( - address(vars.collateralHub), - abi.encodeCall( - IHubBase.remove, - (vars.collateralAssetId, liquidationMetadata.collateralToLiquidator, params.liquidator) - ), - 1 - ); - } + vm.expectCall( + address(vars.collateralHub), + abi.encodeCall( + IHubBase.remove, + ( + vars.collateralAssetId, + liquidationMetadata.collateralAssetsToLiquidator, + params.liquidator + ) + ), + (!params.receiveShares && liquidationMetadata.collateralSharesToLiquidator > 0) ? 1 : 0 + ); vm.expectCall( address(vars.debtHub), abi.encodeCall( IHubBase.restore, - (vars.debtAssetId, vars.baseAmountToRestore, vars.premiumDelta) + (vars.debtAssetId, vars.drawnAmountToRestore, vars.premiumDelta) ), 1 ); - // PayFee call is partially checked, as conversion from assets to shares might differ due to restore donation - if (liquidationMetadata.collateralToLiquidate > liquidationMetadata.collateralToLiquidator) { - vm.expectCall( - address(_hub(params.spoke, params.collateralReserveId)), - abi.encodeWithSelector(IHubBase.payFeeShares.selector) - ); - } + vm.expectCall( + address(_hub(params.spoke, params.collateralReserveId)), + abi.encodeCall( + IHubBase.payFeeShares, + ( + vars.collateralAssetId, + liquidationMetadata.collateralSharesToLiquidate - + liquidationMetadata.collateralSharesToLiquidator + ) + ), + liquidationMetadata.collateralSharesToLiquidate > + liquidationMetadata.collateralSharesToLiquidator + ? 1 + : 0 + ); + + bool riskPremiumOptimisation = accountsInfoBefore.userLastRiskPremium == 0 && + expectedUserAccountData.riskPremium == 0; { for (uint256 i = params.spoke.getReserveCount(); i != 0; ) { @@ -491,8 +484,8 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { uint256 assetId = _reserveAssetId(params.spoke, reserveId); if (reserveId == params.debtReserveId) { - vars.userReservePosition.drawnShares -= _hub(params.spoke, reserveId) - .previewRestoreByAssets(assetId, vars.baseAmountToRestore) + vars.userReservePosition.drawnShares -= liquidationMetadata + .drawnSharesToLiquidate .toUint120(); if (vars.userReservePosition.drawnShares == 0) { continue; @@ -537,14 +530,56 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { emit ISpoke.ReportDeficit({ reserveId: reserveId, user: params.user, - drawnShares: targetHub - .previewRestoreByAssets(assetId, userReserveDrawnDebt) - .toUint120(), + drawnShares: vars.userReservePosition.drawnShares, premiumDelta: premiumDelta }); + } else { + vm.expectCall( + address(targetHub), + abi.encodeWithSelector(IHubBase.reportDeficit.selector, assetId), + 0 + ); + + if (!riskPremiumOptimisation) { + IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta({ + hub: targetHub, + assetId: assetId, + oldPremiumShares: vars.userReservePosition.premiumShares, + oldPremiumOffsetRay: vars.userReservePosition.premiumOffsetRay, + drawnShares: vars.userReservePosition.drawnShares, + riskPremium: expectedUserAccountData.riskPremium, + restoredPremiumRay: 0 + }); + + vm.expectCall( + address(targetHub), + abi.encodeCall(IHubBase.refreshPremium, (assetId, premiumDelta)), + 1 + ); + vm.expectEmit(address(params.spoke)); + emit ISpoke.RefreshPremiumDebt({ + reserveId: reserveId, + user: params.user, + premiumDelta: premiumDelta + }); + } else { + vm.expectCall( + address(targetHub), + abi.encodeWithSelector(IHubBase.refreshPremium.selector, assetId), + 0 + ); + } } } } + + if (!liquidationMetadata.hasDeficit && !riskPremiumOptimisation) { + vm.expectEmit(address(params.spoke)); + emit ISpoke.UpdateUserRiskPremium({ + user: params.user, + riskPremium: expectedUserAccountData.riskPremium + }); + } } } @@ -629,7 +664,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { CheckedLiquidationCallParams memory params, ISpoke.UserAccountData memory userAccountDataBefore ) internal virtual returns (LiquidationMetadata memory) { - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getCalculateDebtToTargetHealthFactorParams( params.spoke, params.collateralReserveId, @@ -637,6 +672,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params.user ) ); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( _getCalculateLiquidationAmountsParams( @@ -648,26 +684,29 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ) ); - ExactLiquidationAmounts memory exactLiquidationAmounts = _getExactLiquidationAmounts( - params, - liquidationAmounts - ); - uint256 liquidationBonus = params.spoke.getLiquidationBonus( params.collateralReserveId, params.user, userAccountDataBefore.healthFactor ); + bool fullDebtReserveLiquidated = liquidationAmounts.drawnSharesToLiquidate == + _getUserDrawnShares(params.spoke, params.debtReserveId, params.user); + + bool hasDeficit = (userAccountDataBefore.activeCollateralCount == 1) && + (liquidationAmounts.collateralSharesToLiquidate == + params.spoke.getUserPosition(params.collateralReserveId, params.user).suppliedShares) && + (userAccountDataBefore.borrowedCount > 1 || !fullDebtReserveLiquidated); + uint256 effectiveLiquidationBonusWad = _calculateEffectiveLiquidationBonusWad( params, - exactLiquidationAmounts + liquidationAmounts ); - assertApproxEqRel( + assertApproxEqAbs( effectiveLiquidationBonusWad, liquidationBonus.bpsToWad(), - _approxRelFromBps(10), + 0.01e18, // 100 basis points 'effective liquidation bonus should be approx equal to liquidation bonus' ); @@ -677,86 +716,50 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { _getCollateralFactor(params.spoke, params.collateralReserveId, params.user) ) > userAccountDataBefore.healthFactor; - ( - uint256 expectedUserRiskPremium, - uint256 expectedUserAvgCollateralFactor - ) = _calculateExpectedUserRiskPremiumAndAvgCollateralFactor( - params, - userAccountDataBefore, - liquidationAmounts.collateralToLiquidate, - liquidationAmounts.debtToLiquidate - ); - - bool hasDeficit = (userAccountDataBefore.activeCollateralCount == 1) && - (!params.isSolvent || isCollateralAffectingUserHf) && - (liquidationAmounts.collateralToLiquidate == - params.spoke.getUserSuppliedAssets(params.collateralReserveId, params.user)); + uint256 drawnIndex = _hub(params.spoke, params.debtReserveId).getAssetDrawnIndex( + _reserveAssetId(params.spoke, params.debtReserveId) + ); + uint256 debtAssetsToLiquidate = _calculateDebtAssetsToRestore({ + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex: drawnIndex + }); + IHubBase collateralHub = _hub(params.spoke, params.collateralReserveId); + uint256 collateralAssetId = _reserveAssetId(params.spoke, params.collateralReserveId); return LiquidationMetadata({ - debtToTarget: debtToTarget, - collateralToLiquidate: liquidationAmounts.collateralToLiquidate, - collateralToLiquidator: liquidationAmounts.collateralToLiquidator, - collateralSharesToLiquidate: exactLiquidationAmounts.collateralSharesToLiquidate, - collateralSharesToLiquidator: exactLiquidationAmounts.collateralSharesToLiquidator, - debtToLiquidate: liquidationAmounts.debtToLiquidate, + debtRayToTarget: debtRayToTarget, + collateralAssetsToLiquidate: collateralHub.previewRemoveByShares( + collateralAssetId, + liquidationAmounts.collateralSharesToLiquidate + ), + collateralAssetsToLiquidator: collateralHub.previewRemoveByShares( + collateralAssetId, + liquidationAmounts.collateralSharesToLiquidator + ), + collateralSharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate, + collateralSharesToLiquidator: liquidationAmounts.collateralSharesToLiquidator, + debtAssetsToLiquidate: debtAssetsToLiquidate, + debtRayToLiquidate: liquidationAmounts.drawnSharesToLiquidate * drawnIndex + + liquidationAmounts.premiumDebtRayToLiquidate, + drawnSharesToLiquidate: liquidationAmounts.drawnSharesToLiquidate, + premiumDebtRayToLiquidate: liquidationAmounts.premiumDebtRayToLiquidate, + debtAssetsToRestore: _calculateDebtAssetsToRestore( + liquidationAmounts.drawnSharesToLiquidate, + liquidationAmounts.premiumDebtRayToLiquidate, + drawnIndex + ), liquidationBonus: liquidationBonus, - expectedUserRiskPremium: expectedUserRiskPremium, - expectedUserAvgCollateralFactor: expectedUserAvgCollateralFactor, - fullDebtReserveLiquidated: exactLiquidationAmounts.fullDebtReserveLiquidated, + fullDebtReserveLiquidated: fullDebtReserveLiquidated, isCollateralAffectingUserHf: isCollateralAffectingUserHf, hasDeficit: hasDeficit }); } - function _getExactLiquidationAmounts( - CheckedLiquidationCallParams memory params, - LiquidationLogic.LiquidationAmounts memory liquidationAmounts - ) internal view returns (ExactLiquidationAmounts memory) { - uint256 collateralSharesToLiquidate = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _reserveAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidate - ); - - uint256 collateralSharesToLiquidator; - if (params.receiveShares && liquidationAmounts.collateralToLiquidator > 0) { - collateralSharesToLiquidator = _hub(params.spoke, params.collateralReserveId) - .previewAddByAssets( - _reserveAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidator - ); - } else { - collateralSharesToLiquidator = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _reserveAssetId(params.spoke, params.collateralReserveId), - liquidationAmounts.collateralToLiquidator - ); - } - - uint256 premiumDebtRayToLiquidate = liquidationAmounts.debtToLiquidate.toRay().min( - params.spoke.getUserPremiumDebtRay(params.debtReserveId, params.user) - ); - uint256 drawnSharesToLiquidate = _hub(params.spoke, params.debtReserveId) - .previewRestoreByAssets( - _reserveAssetId(params.spoke, params.debtReserveId), - liquidationAmounts.debtToLiquidate - premiumDebtRayToLiquidate.fromRayUp() - ); - - return - ExactLiquidationAmounts({ - collateralSharesToLiquidate: collateralSharesToLiquidate, - collateralSharesToLiquidator: collateralSharesToLiquidator, - drawnSharesToLiquidate: drawnSharesToLiquidate, - premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, - fullDebtReserveLiquidated: drawnSharesToLiquidate == - params.spoke.getUserPosition(params.debtReserveId, params.user).drawnShares - }); - } - function _calculateEffectiveLiquidationBonusWad( CheckedLiquidationCallParams memory params, - ExactLiquidationAmounts memory exactLiquidationAmounts + LiquidationLogic.LiquidationAmounts memory liquidationAmounts ) internal view returns (uint256) { uint256 collateralValueRemoved; uint256 debtValueRepaid; @@ -771,7 +774,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { .previewRemoveByShares( _reserveAssetId(params.spoke, params.collateralReserveId), params.spoke.getUserPosition(params.collateralReserveId, params.user).suppliedShares - - exactLiquidationAmounts.collateralSharesToLiquidate + liquidationAmounts.collateralSharesToLiquidate ); collateralValueRemoved = _convertAmountToValue( params.spoke, @@ -783,18 +786,19 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { // debt reserve { uint256 debtBefore = params.spoke.getUserTotalDebt(params.debtReserveId, params.user); - uint256 drawnSharesBefore = params - .spoke - .getUserPosition(params.debtReserveId, params.user) - .drawnShares; + uint256 drawnSharesBefore = _getUserDrawnShares( + params.spoke, + params.debtReserveId, + params.user + ); uint256 premiumDebtRayBefore = params.spoke.getUserPremiumDebtRay( params.debtReserveId, params.user ); uint256 debtAfter = _hub(params.spoke, params.debtReserveId).previewRestoreByShares( _reserveAssetId(params.spoke, params.debtReserveId), - drawnSharesBefore - exactLiquidationAmounts.drawnSharesToLiquidate - ) + (premiumDebtRayBefore - exactLiquidationAmounts.premiumDebtRayToLiquidate).fromRayUp(); + drawnSharesBefore - liquidationAmounts.drawnSharesToLiquidate + ) + (premiumDebtRayBefore - liquidationAmounts.premiumDebtRayToLiquidate).fromRayUp(); debtValueRepaid = _convertAmountToValue( params.spoke, params.debtReserveId, @@ -834,8 +838,8 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { LiquidationMetadata memory liquidationMetadata ) internal virtual { if ( - accountsInfoAfter.userAccountData.totalDebtValue == 0 || - (params.isSolvent && !liquidationMetadata.isCollateralAffectingUserHf) + accountsInfoAfter.userAccountData.totalDebtValueRay == 0 || + !liquidationMetadata.isCollateralAffectingUserHf ) { assertGe( accountsInfoAfter.userAccountData.healthFactor, @@ -850,21 +854,17 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ); } - if (accountsInfoAfter.userAccountData.totalDebtValue == 0) { + if ( + liquidationMetadata.hasDeficit || + (liquidationMetadata.fullDebtReserveLiquidated && + accountsInfoBefore.userAccountData.borrowedCount == 1) + ) { assertEq( accountsInfoAfter.userAccountData.healthFactor, UINT256_MAX, 'health factor should be max if all debt is liquidated' ); - } else if (liquidationMetadata.debtToLiquidate == liquidationMetadata.debtToTarget) { - assertApproxEqRel( - accountsInfoAfter.userAccountData.healthFactor, - _getTargetHealthFactor(params.spoke), - _approxRelFromBps(1), - 'health factor should be approx equal to target health factor' - ); - } else if (liquidationMetadata.debtToLiquidate > liquidationMetadata.debtToTarget) { - // dust adjusted + } else if (liquidationMetadata.debtRayToTarget <= liquidationMetadata.debtRayToLiquidate) { assertGe( accountsInfoAfter.userAccountData.healthFactor, _getTargetHealthFactor(params.spoke), @@ -958,11 +958,12 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { // Hubs address collateralHub = address(_hub(params.spoke, params.collateralReserveId)); address debtHub = address(_hub(params.spoke, params.debtReserveId)); + if (collateralHub == debtHub && params.collateralReserveId == params.debtReserveId) { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'collateral hub: collateral erc20 balance' ); } else { @@ -981,7 +982,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.debtHubBalanceInfo.debtErc20Balance, accountsInfoBefore.debtHubBalanceInfo.debtErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'debt hub: debt erc20 balance' ); if (collateralHub != debtHub) { @@ -1001,7 +1002,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: collateral erc20 balance' ); } else { @@ -1013,7 +1014,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.debtErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.debtErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: debt erc20 balance' ); } @@ -1032,15 +1033,15 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance - - liquidationMetadata.collateralToLiquidator + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.collateralAssetsToLiquidator + + liquidationMetadata.debtAssetsToLiquidate, 'collateral hub: collateral erc20 balance' ); } else { assertEq( accountsInfoAfter.collateralHubBalanceInfo.collateralErc20Balance, accountsInfoBefore.collateralHubBalanceInfo.collateralErc20Balance - - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 'collateral hub: collateral erc20 balance' ); if (collateralHub != debtHub) { @@ -1054,7 +1055,7 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.debtHubBalanceInfo.debtErc20Balance, accountsInfoBefore.debtHubBalanceInfo.debtErc20Balance + - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'debt hub: debt erc20 balance' ); if (collateralHub != debtHub) { @@ -1074,21 +1075,21 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance + - liquidationMetadata.collateralToLiquidator - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.collateralAssetsToLiquidator - + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: collateral erc20 balance' ); } else { assertEq( accountsInfoAfter.liquidatorBalanceInfo.collateralErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.collateralErc20Balance + - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 'liquidator: collateral erc20 balance' ); assertEq( accountsInfoAfter.liquidatorBalanceInfo.debtErc20Balance, accountsInfoBefore.liquidatorBalanceInfo.debtErc20Balance - - liquidationMetadata.debtToLiquidate, + liquidationMetadata.debtAssetsToLiquidate, 'liquidator: debt erc20 balance' ); } @@ -1101,20 +1102,20 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { LiquidationMetadata memory liquidationMetadata ) internal pure { // User - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.userBalanceInfo.suppliedInSpoke, accountsInfoBefore.userBalanceInfo.suppliedInSpoke - - liquidationMetadata.collateralToLiquidate, - _approxRelFromBps(1), + liquidationMetadata.collateralAssetsToLiquidate, + 2, 'user: collateral supplied' ); - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.userBalanceInfo.borrowedFromSpoke, (liquidationMetadata.hasDeficit) ? 0 : accountsInfoBefore.userBalanceInfo.borrowedFromSpoke - - liquidationMetadata.debtToLiquidate, - _approxRelFromBps(1), + liquidationMetadata.debtAssetsToLiquidate, + 2, 'user: debt borrowed' ); @@ -1148,11 +1149,10 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { 'liquidator: collateral supplied' ); } else { - // collateral rounded down on receiveShares, can differ by 2 wei in asset terms assertApproxEqAbs( accountsInfoAfter.liquidatorBalanceInfo.suppliedInSpoke, accountsInfoBefore.liquidatorBalanceInfo.suppliedInSpoke + - liquidationMetadata.collateralToLiquidator, + liquidationMetadata.collateralAssetsToLiquidator, 2, 'liquidator: collateral supplied (receiveShares)' ); @@ -1257,25 +1257,14 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { accountsInfoBefore.collateralFeeReceiverBalanceInfo.drawnFromHub, 'collateral fee receiver: drawn' ); - if (!params.receiveShares) { - assertApproxEqRel( - accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, - accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + - liquidationMetadata.collateralToLiquidate - - liquidationMetadata.collateralToLiquidator, - _approxRelFromBps(1), - 'collateral fee receiver: added' - ); - } else { - assertApproxEqAbs( - accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, - accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + - liquidationMetadata.collateralToLiquidate - - liquidationMetadata.collateralToLiquidator, - 2, - 'collateral fee receiver: added (receiveShares)' - ); - } + assertApproxEqAbs( + accountsInfoAfter.collateralFeeReceiverBalanceInfo.addedInHub, + accountsInfoBefore.collateralFeeReceiverBalanceInfo.addedInHub + + liquidationMetadata.collateralAssetsToLiquidate - + liquidationMetadata.collateralAssetsToLiquidator, + 2, + 'collateral fee receiver: added' + ); if ( _getFeeReceiver(params.spoke, params.collateralReserveId) != @@ -1294,116 +1283,36 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { } // Spoke - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.spokeBalanceInfo.addedInHub, - accountsInfoBefore.spokeBalanceInfo.addedInHub - liquidationMetadata.collateralToLiquidate, - _approxRelFromBps(10), + accountsInfoBefore.spokeBalanceInfo.addedInHub - + ( + params.receiveShares + ? liquidationMetadata.collateralAssetsToLiquidate - + liquidationMetadata.collateralAssetsToLiquidator + : liquidationMetadata.collateralAssetsToLiquidate + ), + 5, 'spoke: added' ); - assertApproxEqRel( + assertApproxEqAbs( accountsInfoAfter.spokeBalanceInfo.drawnFromHub, (liquidationMetadata.hasDeficit) ? 0 - : accountsInfoBefore.spokeBalanceInfo.drawnFromHub - liquidationMetadata.debtToLiquidate, - _approxRelFromBps(1), + : accountsInfoBefore.spokeBalanceInfo.drawnFromHub - + liquidationMetadata.debtAssetsToLiquidate, + 2, 'spoke: drawn' ); } - function _checkTransferSharesCall( + function _checkUserAccountData( CheckedLiquidationCallParams memory params, - LiquidationMetadata memory liquidationMetadata, - Vm.Log[] memory logs - ) internal view { - uint256 transferSharesEventCount = 0; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics[0] == IHubBase.TransferShares.selector) { - transferSharesEventCount += 1; - - assertEq( - uint256(logs[i].topics[1]), - _reserveAssetId(params.spoke, params.collateralReserveId) - ); - address sender = address(uint160(uint256(logs[i].topics[2]))); - address receiver = address(uint160(uint256(logs[i].topics[3]))); - uint256 shares = abi.decode(logs[i].data, (uint256)); - uint256 expectedShares = _hub(params.spoke, params.collateralReserveId) - .previewRemoveByAssets( - _reserveAssetId(params.spoke, params.collateralReserveId), - liquidationMetadata.collateralToLiquidate - liquidationMetadata.collateralToLiquidator - ); - assertApproxEqAbs(shares, expectedShares, 1); - assertEq(sender, address(params.spoke)); - assertEq(receiver, _getFeeReceiver(params.spoke, params.collateralReserveId)); - } - } - - uint256 expectedTransferSharesEventCount = 0; - if ( - !params.receiveShares && - liquidationMetadata.collateralToLiquidate > liquidationMetadata.collateralToLiquidator - ) { - expectedTransferSharesEventCount = 1; - } else if ( - params.receiveShares && - liquidationMetadata.collateralSharesToLiquidate > - liquidationMetadata.collateralSharesToLiquidator - ) { - expectedTransferSharesEventCount = 1; - } - - assertEq( - transferSharesEventCount, - expectedTransferSharesEventCount, - 'transfer shares: event emitted' - ); - } - - function _checkRiskPremium( - CheckedLiquidationCallParams memory params, - AccountsInfo memory accountsInfoBefore, AccountsInfo memory accountsInfoAfter, LiquidationMetadata memory liquidationMetadata, - Vm.Log[] memory logs + ISpoke.UserAccountData memory expectedUserAccountData ) internal view { - uint256 precision = 0.1e18; - uint256 riskPremiumEventCount; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics[0] == ISpoke.UpdateUserRiskPremium.selector) { - riskPremiumEventCount += 1; - - assertEq(address(uint160(uint256(logs[i].topics[1]))), address(params.user)); - uint256 actualUserRiskPremium = abi.decode(logs[i].data, (uint256)); - assertApproxEqRel( - actualUserRiskPremium, - liquidationMetadata.expectedUserRiskPremium, - precision, - 'user risk premium: event' - ); - } - } - - uint256 riskPremiumEventExpectedCount = 1; - if (accountsInfoBefore.userLastRiskPremium == 0 && accountsInfoAfter.userLastRiskPremium == 0) { - riskPremiumEventExpectedCount = 0; - } - assertEq(riskPremiumEventCount, riskPremiumEventExpectedCount, 'riskPremiumEventExpectedCount'); - assertEq( - accountsInfoAfter.userLastRiskPremium, - accountsInfoAfter.userAccountData.riskPremium, - 'user latest risk premium' - ); - - assertApproxEqRel( - accountsInfoAfter.userAccountData.riskPremium, - liquidationMetadata.expectedUserRiskPremium, - precision, - 'user risk premium: user account data' - ); - - if (liquidationMetadata.hasDeficit) { - assertEq(accountsInfoAfter.userLastRiskPremium, 0, 'user risk premium: 0 in deficit'); - } + assertEq(accountsInfoAfter.userAccountData, expectedUserAccountData); for (uint256 reserveId = 0; reserveId < params.spoke.getReserveCount(); reserveId++) { if (_isBorrowing(params.spoke, reserveId, params.user)) { @@ -1419,18 +1328,15 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ); } } - } - function _checkAvgCollateralFactor( - AccountsInfo memory accountsInfoAfter, - LiquidationMetadata memory liquidationMetadata - ) internal pure { - assertApproxEqRel( - accountsInfoAfter.userAccountData.avgCollateralFactor, - liquidationMetadata.expectedUserAvgCollateralFactor, - 0.1e18, - 'user avg collateral factor: user account data' + assertEq( + accountsInfoAfter.userAccountData.riskPremium, + accountsInfoAfter.userLastRiskPremium, + 'user risk premium: user account data' ); + if (liquidationMetadata.hasDeficit) { + assertEq(accountsInfoAfter.userLastRiskPremium, 0, 'user risk premium: 0 in deficit'); + } } function _execBeforeLiquidation(CheckedLiquidationCallParams memory params) internal virtual {} @@ -1442,8 +1348,9 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { ) internal virtual {} function _checkedLiquidationCall(CheckedLiquidationCallParams memory params) internal virtual { - // make sure there is enough liquidity to liquidate - _openSupplyPosition(params.spoke, params.collateralReserveId, MAX_SUPPLY_AMOUNT); + // multiplication by 50 accounts for supply share price increase due to time skip (and interest rate) and for number of supply operations. + // ensures there is enough liquidity to liquidate + _openSupplyPosition(params.spoke, params.collateralReserveId, MAX_SUPPLY_AMOUNT * 50); _execBeforeLiquidation(params); @@ -1452,11 +1359,12 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params, accountsInfoBefore.userAccountData ); - + ISpoke.UserAccountData memory expectedUserAccountData = _calculateExpectedUserAccountData( + params, + liquidationMetadata + ); _assertBeforeLiquidation(params, accountsInfoBefore, liquidationMetadata); - - _expectEventsAndCalls(params, accountsInfoBefore, liquidationMetadata); - vm.recordLogs(); + _expectEventsAndCalls(params, accountsInfoBefore, liquidationMetadata, expectedUserAccountData); vm.prank(params.liquidator); params.spoke.liquidationCall( params.collateralReserveId, @@ -1465,14 +1373,8 @@ contract SpokeLiquidationCallBaseTest is LiquidationLogicBaseTest { params.debtToCover, params.receiveShares ); - Vm.Log[] memory logs = vm.getRecordedLogs(); - AccountsInfo memory accountsInfoAfter = _getAccountsInfo(params); - - _checkTransferSharesCall(params, liquidationMetadata, logs); - _checkRiskPremium(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata, logs); - _checkAvgCollateralFactor(accountsInfoAfter, liquidationMetadata); - + _checkUserAccountData(params, accountsInfoAfter, liquidationMetadata, expectedUserAccountData); _checkPositionStatus(params, liquidationMetadata); _checkHealthFactor(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata); _checkErc20Balances(params, accountsInfoBefore, accountsInfoAfter, liquidationMetadata); diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol index 9c6c225b0..5e3726dd4 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Dust.t.sol @@ -80,7 +80,7 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { }); _borrowToBeAtHf(_spoke, alice, _usdxReserveId(_spoke), 0.9999e18); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getCalculateDebtToTargetHealthFactorParams( _spoke, _daiReserveId(_spoke), @@ -89,10 +89,10 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { ) ); - // debtToTarget (~$11) as limiting factor would result in dust collateral + // debtRayToTarget (~$11) as limiting factor would result in dust collateral assertLt( _getCollateralValue(_spoke, _daiReserveId(_spoke), alice) - - _convertAmountToValue(_spoke, _usdxReserveId(_spoke), debtToTarget), + _convertAmountToValue(_spoke, _usdxReserveId(_spoke), debtRayToTarget.fromRayUp()), LiquidationLogic.DUST_LIQUIDATION_THRESHOLD ); @@ -103,7 +103,7 @@ contract SpokeLiquidationCallDustTest is SpokeLiquidationCallBaseTest { _daiReserveId(_spoke), _usdxReserveId(_spoke), alice, - debtToTarget, + debtRayToTarget.fromRayUp(), false ); diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol deleted file mode 100644 index 82d0b675e..000000000 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Premium.t.sol +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -// Copyright (c) 2025 Aave Labs -pragma solidity ^0.8.0; - -import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol'; - -contract SpokeLiquidationCallPremiumTest is SpokeLiquidationCallHelperTest { - using SafeCast for uint256; - - uint256 internal baseAmountValue; - - function _baseAmountValue() internal virtual override returns (uint256) { - return vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); - } - - function _processAdditionalConfigs( - uint256 collateralReserveId, - uint256 /*debtReserveId*/, - address /*user*/ - ) internal virtual override { - uint256 targetHealthFactor = vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR); - _updateTargetHealthFactor(spoke, targetHealthFactor.toUint120()); - - uint256 liquidationFee = vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE); - _updateLiquidationFee(spoke, collateralReserveId, liquidationFee.toUint16()); - - uint256 liquidationBonus = _randomMaxLiquidationBonus(spoke, collateralReserveId); - _updateMaxLiquidationBonus(spoke, collateralReserveId, liquidationBonus.toUint32()); - - _updateCollateralRisk( - spoke, - collateralReserveId, - vm.randomUint(MIN_COLLATERAL_RISK_BPS, MAX_COLLATERAL_RISK_BPS).toUint24() - ); - } - - function _execBeforeLiquidation(CheckedLiquidationCallParams memory) internal virtual override { - skip(vm.randomUint(1, MAX_SKIP_TIME / 2)); // avoid overflow - } - - function _assertBeforeLiquidation( - CheckedLiquidationCallParams memory params, - AccountsInfo memory /*accountsInfoBefore*/, - LiquidationMetadata memory /*liquidationMetadata*/ - ) internal virtual override { - (, uint256 premiumDebt) = params.spoke.getUserDebt(params.debtReserveId, params.user); - assertGt(premiumDebt, 0, 'premiumDebt: before liquidation, healthy'); - } -} diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol index f89232827..e1b6ba277 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Scenarios.t.sol @@ -5,6 +5,8 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol'; contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { + using SafeCast for *; + address user = makeAddr('user'); address liquidator = makeAddr('liquidator'); @@ -345,7 +347,7 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { // Liquidation bonus: 0 _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 100_00); - // The collateral has a price 10 times higher than the debt + // The collateral has a price 100 times higher than the debt _mockReservePrice(spoke, _wethReserveId(spoke), 100e8); _mockReservePrice(spoke, _daiReserveId(spoke), 1e8); @@ -418,6 +420,297 @@ contract SpokeLiquidationCallScenariosTest is SpokeLiquidationCallBaseTest { ); } + // When liquidation bonus is 0, effective collateral liquidated must be less than effective debt liquidated. + // Full debt is liquidated, and amount of collateral liquidated must be computed based on the effective debt liquidated. + function test_liquidationCall_scenario5() public { + // Liquidation bonus: 0 + _updateMaxLiquidationBonus(spoke, _wethReserveId(spoke), 100_00); + + // Supply share price: 1.25 + _mockSupplySharePrice(hub1, wethAssetId, 12_500.25e6, 10_000e6); + + // The collateral and debt have the same price + _mockReservePrice(spoke, _wethReserveId(spoke), 1e8); + _mockReservePrice(spoke, _daiReserveId(spoke), 1e8); + + // Update WETH collateral factor to 80% + _updateCollateralFactor(spoke, _wethReserveId(spoke), 80_00); + + // Collateral: 3 wei of USDX -> 2 share = 2.5 USDX + _increaseCollateralSupply(spoke, _wethReserveId(spoke), 3, user); + + // Mock interest rate to 10% + _mockInterestRateBps(10_00); + + // Borrow: 1 wei of DAI + _increaseReserveDebt(spoke, _daiReserveId(spoke), 1, user); + + // Skip 1 year to increase drawn index + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(daiAssetId), 1.1e27); + + // Increase DAI price by 101% + _mockReservePriceByPercent(spoke, _daiReserveId(spoke), 201_00); + + // User is fully liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // User position before liquidation + ISpoke.UserPosition memory userCollateralPositionBefore = spoke.getUserPosition( + _wethReserveId(spoke), + user + ); + assertEq(userCollateralPositionBefore.suppliedShares, 2, 'User should have 2 shares of WETH'); + ISpoke.UserPosition memory userDebtPositionBefore = spoke.getUserPosition( + _daiReserveId(spoke), + user + ); + assertEq(userDebtPositionBefore.drawnShares, 1, 'User should have 1 drawn share of DAI'); + assertEq( + userDebtPositionBefore.premiumShares * 1.1e27 - + userDebtPositionBefore.premiumOffsetRay.toUint256(), + 0.1e27, + 'User should have 0.1 premium' + ); + + // Perform liquidation + // 1 drawn share of DAI is liquidated = 1.1 wei of DAI = 2.211 wei of USD = 2.211 wei of WETH = 1.7688 wei of WETH shares + _checkedLiquidationCall( + CheckedLiquidationCallParams({ + spoke: spoke, + collateralReserveId: _wethReserveId(spoke), + debtReserveId: _daiReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + liquidator: liquidator, + isSolvent: true, + receiveShares: false + }) + ); + + // User position after liquidation + ISpoke.UserPosition memory userCollateralPositionAfter = spoke.getUserPosition( + _wethReserveId(spoke), + user + ); + assertEq( + userCollateralPositionAfter.suppliedShares, + 1, + 'User should have 1 share of WETH after liquidation' + ); + ISpoke.UserPosition memory userDebtPositionAfter = spoke.getUserPosition( + _daiReserveId(spoke), + user + ); + assertEq( + userDebtPositionAfter.drawnShares, + 0, + 'User should have 0 drawn share of DAI after liquidation' + ); + assertEq( + userDebtPositionAfter.premiumShares, + 0, + 'User should have 0 premium share of DAI after liquidation' + ); + assertEq( + userDebtPositionAfter.premiumOffsetRay, + 0, + 'User should have 0 premium offset after liquidation' + ); + } + + // When (at least) debtRayToTarget is liquidated, user should not be below target health factor even if debtRayToTarget + // cannot be represented within the precision of the debt token but can be represented within the precision of the collateral token. + function test_liquidationCall_scenario6() public { + // set target health factor to 1 + _updateTargetHealthFactor(spoke, 1e18); + + // mock prices such that dust is not created + _mockReservePrice(spoke, _usdxReserveId(spoke), 1000e14); + _mockReservePrice(spoke, _wbtcReserveId(spoke), 500e17); + _mockReservePrice(spoke, _usdyReserveId(spoke), 1000e27); + + // collateral configs + _updateMaxLiquidationBonus(spoke, _usdxReserveId(spoke), 100_00); + _updateMaxLiquidationBonus(spoke, _wbtcReserveId(spoke), 100_00); + _updateCollateralFactor(spoke, _usdxReserveId(spoke), 70_00); + _updateCollateralFactor(spoke, _wbtcReserveId(spoke), 99_00); + _updateCollateralRisk(spoke, _usdxReserveId(spoke), 0); + _updateCollateralRisk(spoke, _wbtcReserveId(spoke), 0); + + // mock interest rate + _mockInterestRateBps(50_00); + + // User collaterals: 20 wei of USDX, 3 wei of WBTC + // User debt: 1 wei of USDY + _increaseCollateralSupply(spoke, _usdxReserveId(spoke), 20, user); + _increaseCollateralSupply(spoke, _wbtcReserveId(spoke), 3, user); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 2, user); + + ISpoke.UserPosition memory usdxUserPosition = spoke.getUserPosition( + _usdxReserveId(spoke), + user + ); + assertEq( + usdxUserPosition.suppliedShares, + 20, + 'User should have 20 supplied shares of USDX before liquidation' + ); + ISpoke.UserPosition memory usdyUserPosition = spoke.getUserPosition( + _usdyReserveId(spoke), + user + ); + assertEq( + usdyUserPosition.drawnShares, + 2, + 'User should have 2 drawn shares of USDY before liquidation' + ); + + // Skip 1 year to increase drawn index + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 1.5e27); + + // User is liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // Perform liquidation + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _usdxReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + + usdxUserPosition = spoke.getUserPosition(_usdxReserveId(spoke), user); + assertEq( + usdxUserPosition.suppliedShares, + 5, + 'User should have 5 supplied shares of USDX after liquidation' + ); + usdyUserPosition = spoke.getUserPosition(_usdyReserveId(spoke), user); + // check liquidation was partial. since debtToCover was max, it means that target should be reached. + assertEq( + usdyUserPosition.drawnShares, + 1, + 'User should have 1 drawn shares of USDY after liquidation' + ); + + // user should not be liquidatable anymore, which means that he cannot be under the target health factor + vm.expectRevert(ISpoke.HealthFactorNotBelowThreshold.selector); + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _wbtcReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + } + + // When (at least) debtRayToTarget is liquidated, user should not be below target health factor even if debtRayToTarget + // cannot be represented within the precision of the debt token but can be represented within the precision of the collateral token. + function test_liquidationCall_scenario7() public { + // set target health factor to 1 + _updateTargetHealthFactor(spoke, 1e18); + + // mock prices such that dust is not created + _mockReservePrice(spoke, _usdxReserveId(spoke), 1000e14); + _mockReservePrice(spoke, _wbtcReserveId(spoke), 500e17); + _mockReservePrice(spoke, _usdyReserveId(spoke), 1000e27); + + // collateral configs + _updateMaxLiquidationBonus(spoke, _usdxReserveId(spoke), 100_00); + _updateMaxLiquidationBonus(spoke, _wbtcReserveId(spoke), 100_00); + _updateCollateralFactor(spoke, _usdxReserveId(spoke), 70_00); + _updateCollateralFactor(spoke, _wbtcReserveId(spoke), 99_00); + _updateCollateralRisk(spoke, _usdxReserveId(spoke), 50_00); + _updateCollateralRisk(spoke, _wbtcReserveId(spoke), 50_00); + + // set interest rate + _mockInterestRateBps(60_00); + address randomUser = makeAddr('randomUser'); + + // Skip 1 year to increase drawn index to 1.6 + _increaseCollateralSupply(spoke, _usdyReserveId(spoke), 2, randomUser); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 1, randomUser); + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 1.6e27); + + // set interest rate + _mockInterestRateBps(56_25); + + // User collaterals: 40 wei of USDX, 5 wei of WBTC + // User debt: 2 wei of USDY + _increaseCollateralSupply(spoke, _usdxReserveId(spoke), 40, user); + _increaseCollateralSupply(spoke, _wbtcReserveId(spoke), 5, user); + _increaseReserveDebt(spoke, _usdyReserveId(spoke), 2, user); + + ISpoke.UserPosition memory usdxUserPosition = spoke.getUserPosition( + _usdxReserveId(spoke), + user + ); + assertEq( + usdxUserPosition.suppliedShares, + 40, + 'User should have 40 supplied shares of USDX before liquidation' + ); + ISpoke.UserPosition memory usdyUserPosition = spoke.getUserPosition( + _usdyReserveId(spoke), + user + ); + assertEq( + usdyUserPosition.drawnShares, + 2, + 'User should have 2 drawn shares of USDY before liquidation' + ); + + // Skip 1 year to increase drawn index to 2.5 + skip(365 days); + assertEq(hub1.getAssetDrawnIndex(usdyAssetId), 2.5e27); + + // User is liquidatable + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + assertLe(userAccountData.healthFactor, 1e18, 'User should be unhealthy'); + + // Perform liquidation + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _usdxReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + + usdxUserPosition = spoke.getUserPosition(_usdxReserveId(spoke), user); + assertEq( + usdxUserPosition.suppliedShares, + 6, + 'User should have 6 supplied shares of USDX after liquidation' + ); + usdyUserPosition = spoke.getUserPosition(_usdyReserveId(spoke), user); + assertEq( + usdyUserPosition.drawnShares, + 1, + 'User should have 1 drawn shares of USDY after liquidation' + ); + + // user should not be liquidatable anymore, which means that he cannot be under the target health factor + vm.expectRevert(ISpoke.HealthFactorNotBelowThreshold.selector); + vm.prank(liquidator); + spoke.liquidationCall({ + collateralReserveId: _wbtcReserveId(spoke), + debtReserveId: _usdyReserveId(spoke), + user: user, + debtToCover: type(uint256).max, + receiveShares: false + }); + } + /// @dev a halted peripheral asset won't block a liquidation function test_scenario_halted_asset() public { uint256 collateralReserveId = _wethReserveId(spoke); diff --git a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol index 7a489ea83..9128b70f3 100644 --- a/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol +++ b/tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.t.sol @@ -6,79 +6,141 @@ import 'tests/unit/Spoke/Liquidations/Spoke.LiquidationCall.Base.t.sol'; abstract contract SpokeLiquidationCallHelperTest is SpokeLiquidationCallBaseTest { using WadRayMath for uint256; + using SafeCast for uint256; ISpoke spoke; - address liquidator = makeAddr('liquidator'); function setUp() public virtual override { super.setUp(); spoke = spoke1; - vm.prank(SPOKE_ADMIN); - spoke.updateLiquidationConfig( + _updateTargetHealthFactor(spoke, vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR).toUint128()); + _updateLiquidationConfig( + spoke, ISpoke.LiquidationConfig({ - targetHealthFactor: 1.05e18, - healthFactorForMaxBonus: 0.7e18, - liquidationBonusFactor: 20_00 + targetHealthFactor: vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR).toUint128(), + healthFactorForMaxBonus: vm + .randomUint(0, HEALTH_FACTOR_LIQUIDATION_THRESHOLD - 1) + .toUint64(), + liquidationBonusFactor: vm.randomUint(0, PercentageMath.PERCENTAGE_FACTOR).toUint16() }) ); + + for (uint256 i = 0; i < spoke.getReserveCount(); i++) { + _updateMaxLiquidationBonus(spoke, i, _randomMaxLiquidationBonus(spoke, i)); + _updateCollateralFactor(spoke, i, 1); // temporary value to have full range of possibility for liquidation fee + _updateLiquidationFee( + spoke, + i, + vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE).toUint16() + ); + _updateCollateralFactor(spoke, i, _randomCollateralFactor(spoke, i)); + } } - function _baseAmountValue() internal virtual returns (uint256); + function _user() internal virtual returns (address) { + return makeAddr('user'); + } - function _processAdditionalConfigs( - uint256 collateralReserveId, - uint256 debtReserveId, - address user - ) internal virtual {} + function _liquidator() internal virtual returns (address) { + return makeAddr('liquidator'); + } + + function _baseAmountValue() internal virtual returns (uint256) { + return vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); + } + + function _skipTime() internal virtual returns (uint256) { + return vm.randomUint(0, 365 days); + } + + function _processAdditionalSetup( + uint256 /* collateralReserveId */, + uint256 /* debtReserveId */ + ) internal virtual { + // user enables more collaterals, but still has deficit given that only one collateral is supplied + for (uint256 reserveId = 0; reserveId < spoke.getReserveCount(); reserveId++) { + if (vm.randomBool()) { + Utils.setUsingAsCollateral(spoke, reserveId, _user(), true, _user()); + } + } + } + + function _processAdditionalCollateralReserves(uint256 debtReserveId) internal { + // division by 100 accounts for supply share price increase due to time skip (and interest rate) and user's avg collateral factor + // ensures debt required to make user liquidatable does not exceed max supply amount + uint256 suppliableValue = (_convertAmountToValue( + spoke, + debtReserveId, + _calculateMaxSupplyAmount(spoke, debtReserveId) + ) - _baseAmountValue()) / 100; - function _processAdditionalCollateralReserves(address user, uint256 amountValue) internal { - uint256 count = vm.randomUint(1, 10); + uint256 count = vm.randomUint(1, spoke.getReserveCount() * 2); for (uint256 i = 0; i < count; i++) { uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); - uint256 amount = _convertValueToAmount(spoke, reserveId, amountValue); - _increaseCollateralSupply(spoke, reserveId, amount, user); + uint256 maxAmount = _convertValueToAmount(spoke, reserveId, suppliableValue); + if (maxAmount == 0) { + require(i > 0, 'No supply operations'); + break; + } + uint256 amount = vm.randomUint(1, maxAmount); + suppliableValue -= _convertAmountToValue(spoke, reserveId, amount); + _increaseCollateralSupply(spoke, reserveId, amount, _user()); } } - function _processAdditionalDebtReserves(address user, uint256 amountValue) internal { - uint256 count = vm.randomUint(1, 10); + function _processAdditionalDebtReserves() internal { + uint256 count = vm.randomUint(1, spoke.getReserveCount() * 2); + // division by 2 accounts for borrow share price increase due to time skip (and borrow interest rate) + // ensures user is healthy enough to borrow these amounts + uint256 borrowableValue = _getRequiredDebtValueForHf( + spoke, + _user(), + Constants.HEALTH_FACTOR_LIQUIDATION_THRESHOLD + ) / 2; for (uint256 i = 0; i < count; i++) { uint256 reserveId = vm.randomUint(0, spoke.getReserveCount() - 1); - uint256 amount = _convertValueToAmount(spoke, reserveId, amountValue); - _increaseReserveDebt(spoke, reserveId, amount, user); + uint256 maxBorrowAmount = _convertValueToAmount(spoke, reserveId, borrowableValue); + if (maxBorrowAmount == 0) { + require(i > 0, 'No borrow operations'); + break; + } + uint256 amount = vm.randomUint(1, maxBorrowAmount); + borrowableValue -= _convertAmountToValue(spoke, reserveId, amount); + _increaseReserveDebt(spoke, reserveId, amount, _user()); } } function _testLiquidationCall( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool isSolvent, bool receiveShares ) internal virtual { - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); + skip(_skipTime()); + + ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(_user()); uint256 newHealthFactor; // new health factor of user, just before liquidation if (isSolvent) { // health factor of user should be at least its average collateral factor newHealthFactor = vm.randomUint( - userAccountData.avgCollateralFactor + 0.01e18, - PercentageMath.PERCENTAGE_FACTOR.bpsToWad() - 0.01e18 + userAccountData.avgCollateralFactor + 0.0000001e18, + PercentageMath.PERCENTAGE_FACTOR.bpsToWad() - 0.0000001e18 ); } else { - newHealthFactor = vm.randomUint(0.01e18, userAccountData.avgCollateralFactor - 0.01e18); + newHealthFactor = vm.randomUint(0.01e18, userAccountData.avgCollateralFactor - 0.0000001e18); } - _makeUserLiquidatable(spoke, user, debtReserveId, newHealthFactor); + _makeUserLiquidatable(spoke, _user(), debtReserveId, newHealthFactor); debtToCover = _boundDebtToCoverNoDustRevert( spoke, collateralReserveId, debtReserveId, - user, + _user(), debtToCover, - liquidator + _liquidator() ); _checkedLiquidationCall( @@ -86,9 +148,9 @@ abstract contract SpokeLiquidationCallHelperTest is SpokeLiquidationCallBaseTest spoke: spoke, collateralReserveId: collateralReserveId, debtReserveId: debtReserveId, - user: user, + user: _user(), debtToCover: debtToCover, - liquidator: liquidator, + liquidator: _liquidator(), isSolvent: isSolvent, receiveShares: receiveShares }) @@ -98,521 +160,343 @@ abstract contract SpokeLiquidationCallHelperTest is SpokeLiquidationCallBaseTest function test_liquidationCall_fuzz_OneCollateral_OneDebt_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_OneDebt_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - // user enables more collaterals, but still has deficit given that only one collateral is supplied - for (uint256 reserveId = 0; reserveId < spoke.getReserveCount(); reserveId++) { - if (vm.randomBool()) { - Utils.setUsingAsCollateral(spoke, reserveId, user, true, user); - } - } - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_OneDebt_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalCollateralReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_OneDebt_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalCollateralReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_ManyDebts_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_OneCollateral_ManyDebts_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_ManyDebts_UserSolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalCollateralReserves(user, 1e26); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - true, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, true, receiveShares); } function test_liquidationCall_fuzz_ManyCollaterals_ManyDebts_UserInsolvent( uint256 collateralReserveId, uint256 debtReserveId, - address user, uint256 debtToCover, bool receiveShares ) public virtual { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - - _processAdditionalConfigs(collateralReserveId, debtReserveId, user); + (collateralReserveId, debtReserveId) = _bound(spoke, collateralReserveId, debtReserveId); + _processAdditionalSetup(collateralReserveId, debtReserveId); _increaseCollateralSupply( spoke, collateralReserveId, _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + _user() ); - _processAdditionalCollateralReserves(user, 1e26); - _processAdditionalDebtReserves(user, 1e26); + _processAdditionalCollateralReserves(debtReserveId); + _processAdditionalDebtReserves(); - _testLiquidationCall( - collateralReserveId, - debtReserveId, - user, - debtToCover, - false, - receiveShares - ); + _testLiquidationCall(collateralReserveId, debtReserveId, debtToCover, false, receiveShares); } +} - function test_validateLiquidationCall_revertsWith_ReserveNotListed_CollateralReserve( - uint256 collateralId, - uint256 debtId - ) public { - collateralId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - debtId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - vm.expectRevert(ISpoke.ReserveNotListed.selector); - spoke.liquidationCall( - collateralId, - debtId, - vm.randomAddress(), - vm.randomUint(), - vm.randomBool() - ); +contract SpokeLiquidationCallTest_SmallPosition is SpokeLiquidationCallHelperTest { + function _baseAmountValue() internal virtual override returns (uint256) { + return vm.randomUint(1e26, 10_000e26); } +} - function test_validateLiquidationCall_revertsWith_ReserveNotListed_DebtReserve( - uint256 collateralId, - uint256 debtId - ) public { - collateralId = vm.randomUint(0, spoke.getReserveCount() - 1); - debtId = vm.randomUint(spoke.getReserveCount(), UINT256_MAX); - vm.expectRevert(ISpoke.ReserveNotListed.selector); - spoke.liquidationCall( - collateralId, - debtId, - vm.randomAddress(), - vm.randomUint(), - vm.randomBool() - ); +contract SpokeLiquidationCallTest_LargePosition is SpokeLiquidationCallHelperTest { + function _baseAmountValue() internal virtual override returns (uint256) { + return vm.randomUint(100_000e26, 1_000_000_000e26); } +} - function test_validateLiquidationCall_revertsWith_CannotReceiveShares( +contract SpokeLiquidationCallTest_NoLiquidationBonus is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( uint256 collateralReserveId, - uint256 debtReserveId, - address user, - uint256 debtToCover - ) public { - (collateralReserveId, debtReserveId, user) = _boundAssume( - spoke, - collateralReserveId, - debtReserveId, - user, - liquidator - ); - _updateReserveReceiveSharesEnabledFlag(spoke, collateralReserveId, false); + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateMaxLiquidationBonus(spoke, collateralReserveId, 100_00); + } - _increaseCollateralSupply( + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal virtual override { + assertEq(liquidationMetadata.liquidationBonus, 100_00, 'Liquidation bonus'); + } +} + +contract SpokeLiquidationCallTest_SmallLiquidationBonus is SpokeLiquidationCallHelperTest { + using PercentageMath for *; + using SafeCast for uint256; + + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateCollateralFactor(spoke, collateralReserveId, 1); // temporary value to have full range of possibility for liquidation bonus + _updateMaxLiquidationBonus( spoke, collateralReserveId, - _convertValueToAmount(spoke, collateralReserveId, _baseAmountValue()), - user + vm.randomUint(MIN_LIQUIDATION_BONUS, MIN_LIQUIDATION_BONUS.percentMulUp(102_00)).toUint32() ); - - ISpoke.UserAccountData memory userAccountData = spoke.getUserAccountData(user); - uint256 newHealthFactor = vm.randomUint( - userAccountData.avgCollateralFactor + 0.01e18, - PercentageMath.PERCENTAGE_FACTOR.bpsToWad() - 0.01e18 - ); - _makeUserLiquidatable(spoke, user, debtReserveId, newHealthFactor); - debtToCover = _boundDebtToCoverNoDustRevert( + _updateLiquidationBonusFactor(spoke, 100_00); + _updateCollateralFactor( spoke, collateralReserveId, - debtReserveId, - user, - debtToCover, - liquidator + _randomCollateralFactor(spoke, collateralReserveId) ); - - vm.expectRevert(ISpoke.CannotReceiveShares.selector); - vm.prank(liquidator); - spoke.liquidationCall(collateralReserveId, debtReserveId, user, debtToCover, true); } -} -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_NoLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal virtual override { + assertLe( + liquidationMetadata.liquidationBonus, + MAX_LIQUIDATION_BONUS.percentMulUp(102_00), + 'Liquidation bonus' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_NoLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; - } -} +contract SpokeLiquidationCallTest_LargeLiquidationBonus is SpokeLiquidationCallHelperTest { + using PercentageMath for *; + using SafeCast for *; -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_SmallLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = 105_00; - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateCollateralFactor(spoke, collateralReserveId, 1); // temporary value to have full range of possibility for liquidation bonus + _updateMaxLiquidationBonus( + spoke, + collateralReserveId, + vm.randomUint(MAX_LIQUIDATION_BONUS.percentMulDown(97_00), MAX_LIQUIDATION_BONUS).toUint32() + ); + _updateLiquidationBonusFactor(spoke, 100_00); + _updateCollateralFactor( + spoke, + collateralReserveId, + _randomCollateralFactor(spoke, collateralReserveId) + ); } - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory /* params */, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory liquidationMetadata + ) internal virtual override { + assertGe( + liquidationMetadata.liquidationBonus, + MAX_LIQUIDATION_BONUS.percentMulDown(97_00), + 'Liquidation bonus' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_SmallLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = 105_00; - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } +contract SpokeLiquidationCallTest_LiquidationFeeZero is SpokeLiquidationCallHelperTest { + function _processAdditionalSetup( + uint256 collateralReserveId, + uint256 debtReserveId + ) internal virtual override { + super._processAdditionalSetup(collateralReserveId, debtReserveId); + _updateLiquidationFee(spoke, collateralReserveId, 0); } - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal virtual override { + assertEq( + _getLiquidationFee(params.spoke, params.collateralReserveId, params.user), + 0, + 'Liquidation fee' + ); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_LargeLiquidationBonus_SmallPosition is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; - using SafeCast for uint256; - +contract SpokeLiquidationCallTest_NoPremium is SpokeLiquidationCallHelperTest { function setUp() public virtual override { super.setUp(); for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); + _updateCollateralRisk(spoke, i, 0); } } - function _baseAmountValue() internal virtual override returns (uint256) { - return 100e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal virtual override { + (, uint256 premiumDebt) = params.spoke.getUserDebt(params.debtReserveId, params.user); + assertEq(premiumDebt, 0, 'No premium'); } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_LargeLiquidationBonus_LargePosition is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; - using SafeCast for uint256; - - function setUp() public virtual override { - super.setUp(); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } +contract SpokeLiquidationCallTest_NoTimeSkip is SpokeLiquidationCallHelperTest { + function _skipTime() internal virtual override returns (uint256) { + return 0; } - function _baseAmountValue() internal virtual override returns (uint256) { - return 10000e26; + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ + ) internal virtual override { + uint256 reserveCount = params.spoke.getReserveCount(); + for (uint256 i = 0; i < reserveCount; i++) { + assertEq(_reserveDrawnIndex(params.spoke, i), 1e27, 'drawn index'); + IHub hub = _hub(params.spoke, i); + uint256 assetId = _reserveAssetId(params.spoke, i); + assertEq(hub.getAddedAssets(assetId), hub.getAddedShares(assetId), 'supply share price'); + } } } -/// forge-config: pr.fuzz.runs = 1000 -contract SpokeLiquidationCallTest_TargetHealthFactor_LiquidationFee is - SpokeLiquidationCallHelperTest -{ - using PercentageMath for uint256; - using SafeCast for uint256; - - uint256 internal baseAmountValue; - +contract SpokeLiquidationCallTest_TargetHealthFactorOne is SpokeLiquidationCallHelperTest { function setUp() public virtual override { super.setUp(); - baseAmountValue = vm.randomUint(MIN_AMOUNT_IN_BASE_CURRENCY, MAX_AMOUNT_IN_BASE_CURRENCY); - for (uint256 i = 0; i < spoke.getReserveCount(); i++) { - ISpoke.DynamicReserveConfig memory dynConfig = spoke.getDynamicReserveConfig( - i, - spoke.getUserPosition(i, liquidator).dynamicConfigKey - ); - dynConfig.maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, i); - vm.prank(SPOKE_ADMIN); - spoke.addDynamicReserveConfig(i, dynConfig); - } + _updateTargetHealthFactor(spoke, 1e18); } - function _baseAmountValue() internal virtual override returns (uint256) { - return baseAmountValue; - } - - function _processAdditionalConfigs( - uint256 collateralReserveId, - uint256, - address + function _assertBeforeLiquidation( + CheckedLiquidationCallParams memory params, + AccountsInfo memory /* accountsInfoBefore */, + LiquidationMetadata memory /* liquidationMetadata */ ) internal virtual override { - uint256 targetHealthFactor = vm.randomUint(MIN_CLOSE_FACTOR, MAX_CLOSE_FACTOR); - _updateTargetHealthFactor(spoke, targetHealthFactor.toUint128()); - - uint32 maxLiquidationBonus = _randomMaxLiquidationBonus(spoke, collateralReserveId); - _updateMaxLiquidationBonus(spoke, collateralReserveId, maxLiquidationBonus); - - uint256 liquidationFee = vm.randomUint(MIN_LIQUIDATION_FEE, MAX_LIQUIDATION_FEE); - _updateLiquidationFee(spoke, collateralReserveId, liquidationFee.toUint16()); + assertEq(params.spoke.getLiquidationConfig().targetHealthFactor, 1e18, 'Target health factor'); } } diff --git a/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol b/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol index 6f183d9e2..ebab75d0f 100644 --- a/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueInterest.Scenario.t.sol @@ -782,53 +782,54 @@ contract SpokeAccrueInterestScenarioTest is SpokeBase { ISpoke spoke, TestAmounts memory amounts ) internal view returns (TestAmounts memory) { - uint256 remainingCollateralValue = _getValue( + uint256 remainingCollateralValue = _convertAmountToValue( spoke, _daiReserveId(spoke), amounts.daiSupplyAmount ) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); // Bound each debt amount to be no more than half the remaining collateral value amounts.daiBorrowAmount = bound( amounts.daiBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _daiReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _daiReserveId(spoke), 1) ); // Subtract out the set debt value from the remaining collateral value - remainingCollateralValue -= _getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; + remainingCollateralValue -= + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; amounts.wethBorrowAmount = bound( amounts.wethBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wethReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wethReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; amounts.usdxBorrowAmount = bound( amounts.usdxBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _usdxReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _usdxReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; amounts.wbtcBorrowAmount = bound( amounts.wbtcBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wbtcReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wbtcReserveId(spoke), 1) ); assertGt( - _getValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), 2 * - (_getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), + (_convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), 'collateral sufficiently covers debt' ); diff --git a/tests/unit/Spoke/Spoke.AccrueInterest.t.sol b/tests/unit/Spoke/Spoke.AccrueInterest.t.sol index 5e66de914..8ef2095a8 100644 --- a/tests/unit/Spoke/Spoke.AccrueInterest.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueInterest.t.sol @@ -256,6 +256,7 @@ contract SpokeAccrueInterestTest is SpokeBase { TestAmounts memory amounts, uint40 skipTime ) public { + vm.skip(true, 'pending rft'); amounts = _bound(amounts); skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); @@ -1052,15 +1053,15 @@ contract SpokeAccrueInterestTest is SpokeBase { ); } - function _bound(TestAmounts memory amounts) internal pure returns (TestAmounts memory) { - amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); + function _bound(TestAmounts memory amounts) internal view returns (TestAmounts memory) { + amounts.daiSupplyAmount = bound(amounts.daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + amounts.wethSupplyAmount = bound(amounts.wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + amounts.usdxSupplyAmount = bound(amounts.usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + amounts.wbtcSupplyAmount = bound(amounts.wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); + amounts.daiBorrowAmount = bound(amounts.daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + amounts.wethBorrowAmount = bound(amounts.wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + amounts.usdxBorrowAmount = bound(amounts.usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + amounts.wbtcBorrowAmount = bound(amounts.wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); return amounts; } @@ -1086,53 +1087,54 @@ contract SpokeAccrueInterestTest is SpokeBase { ISpoke spoke, TestAmounts memory amounts ) internal view returns (TestAmounts memory) { - uint256 remainingCollateralValue = _getValue( + uint256 remainingCollateralValue = _convertAmountToValue( spoke, _daiReserveId(spoke), amounts.daiSupplyAmount ) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount); // Bound each debt amount to be no more than half the remaining collateral value amounts.daiBorrowAmount = bound( amounts.daiBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _daiReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _daiReserveId(spoke), 1) ); // Subtract out the set debt value from the remaining collateral value - remainingCollateralValue -= _getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; + remainingCollateralValue -= + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) * 2; amounts.wethBorrowAmount = bound( amounts.wethBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wethReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wethReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) * 2; amounts.usdxBorrowAmount = bound( amounts.usdxBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _usdxReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _usdxReserveId(spoke), 1) ); remainingCollateralValue -= - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) * 2; amounts.wbtcBorrowAmount = bound( amounts.wbtcBorrowAmount, 0, - (remainingCollateralValue / 2) / _getValue(spoke, _wbtcReserveId(spoke), 1) + (remainingCollateralValue / 2) / _convertAmountToValue(spoke, _wbtcReserveId(spoke), 1) ); assertGt( - _getValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), + _convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiSupplyAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethSupplyAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxSupplyAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcSupplyAmount), 2 * - (_getValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + - _getValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + - _getValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + - _getValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), + (_convertAmountToValue(spoke, _daiReserveId(spoke), amounts.daiBorrowAmount) + + _convertAmountToValue(spoke, _wethReserveId(spoke), amounts.wethBorrowAmount) + + _convertAmountToValue(spoke, _usdxReserveId(spoke), amounts.usdxBorrowAmount) + + _convertAmountToValue(spoke, _wbtcReserveId(spoke), amounts.wbtcBorrowAmount)), 'collateral sufficiently covers debt' ); diff --git a/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol b/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol index cfded4d60..4c142b33c 100644 --- a/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol +++ b/tests/unit/Spoke/Spoke.AccrueLiquidityFee.EdgeCases.t.sol @@ -24,13 +24,14 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 skipTime, uint256 rate ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); // within collateralization rate = bound(rate, 1, MAX_BORROW_RATE); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); uint256 assetId = spoke1.getReserve(reserveId).assetId; + borrowAmount = bound(borrowAmount, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 2); // within collateralization + updateLiquidityFee(hub1, assetId, MAX_LIQUIDITY_FEE); uint256 supplyAmount = _calcMinimumCollAmount(spoke1, reserveId, reserveId, borrowAmount); @@ -73,12 +74,12 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 skipTime, uint256 rate ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 4); // within collateralization - borrowAmount2 = bound(borrowAmount2, 1, MAX_SUPPLY_AMOUNT / 4); // within collateralization rate = bound(rate, 1, MAX_BORROW_RATE); skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); uint256 assetId = spoke1.getReserve(reserveId).assetId; + borrowAmount = bound(borrowAmount, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 4); // within collateralization + borrowAmount2 = bound(borrowAmount2, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 4); // within collateralization updateLiquidityFee(hub1, spoke1.getReserve(reserveId).assetId, MAX_LIQUIDITY_FEE); @@ -133,7 +134,7 @@ contract SpokeAccrueLiquidityFeeEdgeCasesTest is SpokeBase { uint256 count = vm.randomUint(10, 1000); for (uint256 i; i < count; ++i) { address user = makeUser(i); - uint256 borrowAmount = vm.randomUint(1, MAX_SUPPLY_AMOUNT / count); + uint256 borrowAmount = vm.randomUint(1, _calculateMaxSupplyAmount(spoke1, reserveId) / count); _backedBorrow(spoke1, user, reserveId, reserveId, borrowAmount); } uint256 totalOwedBefore = hub1.getAssetTotalOwed(assetId); diff --git a/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol b/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol index 2a173b96f..de6124386 100644 --- a/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Borrow.Scenario.t.sol @@ -15,10 +15,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { uint256 daiBorrowAmount2, uint256 usdxBorrowAmount2 ) public { - daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 4); - usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 4); - daiBorrowAmount2 = bound(daiBorrowAmount2, 0, MAX_SUPPLY_AMOUNT / 4); - usdxBorrowAmount2 = bound(usdxBorrowAmount2, 0, MAX_SUPPLY_AMOUNT / 4); + daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 4); + daiBorrowAmount2 = bound(daiBorrowAmount2, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + usdxBorrowAmount2 = bound(usdxBorrowAmount2, 0, MAX_SUPPLY_AMOUNT_USDX / 4); BorrowTestData memory state; @@ -41,9 +41,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.daiBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.daiReserveId, bob); state.usdxBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.usdxReserveId, bob); - state.wethAlice.supplyAmount = state.wbtcAlice.supplyAmount = state.wethBob.supplyAmount = state - .wbtcBob - .supplyAmount = MAX_SUPPLY_AMOUNT / 2; + state.wethAlice.supplyAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + state.wbtcAlice.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC / 2; + state.wethBob.supplyAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + state.wbtcBob.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC / 2; // Alice supply collateral through spoke1 Utils.supplyCollateral(spoke1, state.wethReserveId, alice, state.wethAlice.supplyAmount, alice); @@ -171,10 +172,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.usdxReserveId = _usdxReserveId(spoke2); state.wbtcReserveId = _wbtcReserveId(spoke2); - daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); - wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT / 2); + daiBorrowAmount = bound(daiBorrowAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 4); + wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 4); + usdxBorrowAmount = bound(usdxBorrowAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 4); + wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 4); // should be 0 because no realized premium yet state.daiBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.daiReserveId, bob); @@ -182,9 +183,10 @@ contract SpokeBorrowScenarioTest is SpokeBase { state.usdxBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.usdxReserveId, bob); state.wbtcBob.premiumDebtRayBefore = _calculatePremiumDebtRay(spoke1, state.wbtcReserveId, bob); - state.daiBob.supplyAmount = state.wethBob.supplyAmount = state.usdxBob.supplyAmount = state - .wbtcBob - .supplyAmount = MAX_SUPPLY_AMOUNT; + state.daiBob.supplyAmount = MAX_SUPPLY_AMOUNT_DAI; + state.wethBob.supplyAmount = MAX_SUPPLY_AMOUNT_WETH; + state.usdxBob.supplyAmount = MAX_SUPPLY_AMOUNT_USDX; + state.wbtcBob.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; // Bob supply all reserves as collateral Utils.supplyCollateral(spoke2, state.daiReserveId, bob, state.daiBob.supplyAmount, bob); @@ -567,7 +569,7 @@ contract SpokeBorrowScenarioTest is SpokeBase { assertEq(_getCollateralFactor(spoke1, coll1ReserveId), 0); // initially assertNotEq(_getCollateralFactor(spoke1, coll2ReserveId), 0); - uint256 coll2Value = _getValue(spoke1, coll2ReserveId, coll2Amount); + uint256 coll2Value = _convertAmountToValue(spoke1, coll2ReserveId, coll2Amount); Utils.supplyCollateral(spoke1, coll1ReserveId, alice, coll1Amount, alice); Utils.supplyCollateral(spoke1, coll2ReserveId, alice, coll2Amount, alice); diff --git a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol index ca16599c3..ec26f9d38 100644 --- a/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol +++ b/tests/unit/Spoke/Spoke.DynamicConfig.Triggers.t.sol @@ -294,8 +294,8 @@ contract SpokeDynamicConfigTriggersTest is SpokeBase { // Alice's dai debt is exactly covered by her weth collateral assertEq( - _getValue(spoke1, _daiReserveId(spoke1), 2000e18), - _getValue(spoke1, _wethReserveId(spoke1), 1e18), + _convertAmountToValue(spoke1, _daiReserveId(spoke1), 2000e18), + _convertAmountToValue(spoke1, _wethReserveId(spoke1), 1e18), 'weth supply covers debt' ); diff --git a/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol b/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol index d7c8cbe20..5e6add00d 100644 --- a/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Repay.Scenario.t.sol @@ -1325,9 +1325,9 @@ contract SpokeRepayScenarioTest is SpokeBase { _assumeValidSupplier(caller); vm.assume(caller != derl); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); - userBorrowing = bound(userBorrowing, 0, MAX_SUPPLY_AMOUNT / 2 - 1); // Allow some buffer from borrow cap + userBorrowing = bound(userBorrowing, 0, _calculateMaxSupplyAmount(spoke1, reserveId) / 2 - 1); // Allow some buffer from borrow cap skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); - assets = bound(assets, 1, MAX_SUPPLY_AMOUNT / 2 - userBorrowing); + assets = bound(assets, 1, _calculateMaxSupplyAmount(spoke1, reserveId) / 2 - userBorrowing); // Set up initial state of the vault by having derl borrow uint256 supplyAmount = _calcMinimumCollAmount(spoke1, reserveId, reserveId, userBorrowing); @@ -1343,7 +1343,7 @@ contract SpokeRepayScenarioTest is SpokeBase { IERC20 underlying = getAssetUnderlyingByReserveId(spoke1, reserveId); // Deal caller max collateral amount, approve spoke, supply - supplyAmount = MAX_SUPPLY_AMOUNT - supplyAmount; + supplyAmount = _calculateMaxSupplyAmount(spoke1, reserveId) - supplyAmount; deal(address(underlying), caller, supplyAmount); vm.prank(caller); underlying.approve(address(spoke1), supplyAmount); @@ -1375,10 +1375,10 @@ contract SpokeRepayScenarioTest is SpokeBase { address caller, uint256 assets ) public { - uint256 MAX_BORROW_AMOUNT = MAX_SUPPLY_AMOUNT / 2; _assumeValidSupplier(caller); vm.assume(caller != derl); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + uint256 MAX_BORROW_AMOUNT = _calculateMaxSupplyAmount(spoke1, reserveId) / 2; userBorrowing = bound(userBorrowing, 0, MAX_BORROW_AMOUNT - 2); // Allow some buffer from borrow cap skipTime = bound(skipTime, 0, MAX_SKIP_TIME).toUint40(); assets = bound(assets, 1, MAX_BORROW_AMOUNT - userBorrowing - 1); // Allow some buffer from borrow cap @@ -1398,7 +1398,7 @@ contract SpokeRepayScenarioTest is SpokeBase { IERC20 underlying = getAssetUnderlyingByReserveId(spoke1, reserveId); // Set up caller initial debt position - supplyAmount = MAX_SUPPLY_AMOUNT - supplyAmount; + supplyAmount = _calculateMaxSupplyAmount(spoke1, reserveId) - supplyAmount; deal(address(underlying), caller, supplyAmount); vm.prank(caller); underlying.approve(address(spoke1), supplyAmount); diff --git a/tests/unit/Spoke/Spoke.Repay.t.sol b/tests/unit/Spoke/Spoke.Repay.t.sol index dbec2cb81..736d352f2 100644 --- a/tests/unit/Spoke/Spoke.Repay.t.sol +++ b/tests/unit/Spoke/Spoke.Repay.t.sol @@ -1435,10 +1435,10 @@ contract SpokeRepayTest is SpokeBase { RepayMultipleLocal memory usdxInfo; RepayMultipleLocal memory wbtcInfo; - daiInfo.borrowAmount = bound(daiBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - wethInfo.borrowAmount = bound(wethBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - usdxInfo.borrowAmount = bound(usdxBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - wbtcInfo.borrowAmount = bound(wbtcBorrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); + daiInfo.borrowAmount = bound(daiBorrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); + wethInfo.borrowAmount = bound(wethBorrowAmount, 1, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxInfo.borrowAmount = bound(usdxBorrowAmount, 1, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcInfo.borrowAmount = bound(wbtcBorrowAmount, 1, MAX_SUPPLY_AMOUNT_WBTC / 2); repayPortion = bound(repayPortion, 0, PercentageMath.PERCENTAGE_FACTOR); skipTime = bound(skipTime, 1, MAX_SKIP_TIME).toUint40(); diff --git a/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol index 74fd99363..b2aef95ae 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.EdgeCases.t.sol @@ -343,8 +343,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be greater than or equal collateral risk of dai, since debt is not fully covered by it (and due to rounding) assertGt( - _getValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), 'Weth borrow amount greater than dai supply amount' ); assertGe( @@ -364,8 +364,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { uint256 daiSupplied = spoke2.getUserSuppliedAssets(_daiReserveId(spoke2), bob); uint256 bobWethDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplied), - _getValue(spoke2, _wethReserveId(spoke2), bobWethDebt), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplied), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobWethDebt), 'Bob dai collateral exceeds weth debt after interest accrual' ); @@ -455,8 +455,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral equals weth debt' ); assertEq( @@ -475,8 +475,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Ensure debt has grown beyond dai collateral uint256 bobDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _wethReserveId(spoke2), bobDebt), - _getValue( + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobDebt), + _convertAmountToValue( spoke2, _daiReserveId(spoke2), spoke2.getUserSuppliedAssets(_daiReserveId(spoke2), bob) @@ -562,8 +562,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethBorrowAmount), 'Bob weth collateral equals dai debt' ); assertEq( @@ -602,8 +602,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Ensure Bob's weth debt has grown beyond dai collateral uint256 bobDebt = spoke2.getUserTotalDebt(_wethReserveId(spoke2), bob); assertGt( - _getValue(spoke2, _wethReserveId(spoke2), bobDebt), - _getValue(spoke2, _daiReserveId(spoke2), bobDaiCollateral), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), bobDebt), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), bobDaiCollateral), 'Bob weth debt exceeds dai collateral after 1 year' ); @@ -670,8 +670,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of dai, since debt is fully covered by it assertEq( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _daiReserveId(spoke2), initialBorrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), initialBorrowAmount), 'Bob dai collateral equals dai debt' ); assertEq( @@ -699,8 +699,12 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Now dai collateral is insufficient to cover the debt assertLt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _daiReserveId(spoke2), spoke2.getUserTotalDebt(_daiReserveId(spoke2), bob)), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue( + spoke2, + _daiReserveId(spoke2), + spoke2.getUserTotalDebt(_daiReserveId(spoke2), bob) + ), 'Bob wbtc collateral less than dai debt' ); @@ -766,8 +770,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be equal to collateral risk of weth, since debt is fully covered by it assertGt( - _getValue(spoke1, _wethReserveId(spoke1), wethSupplyAmount), - _getValue(spoke1, _daiReserveId(spoke1), borrowAmount), + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethSupplyAmount), + _convertAmountToValue(spoke1, _daiReserveId(spoke1), borrowAmount), 'Bob weth collateral enough to cover dai debt' ); assertEq( @@ -851,8 +855,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Bob's current risk premium should be greater than or equal to collateral risk of dai, since debt is not fully covered by it (and due to rounding) assertLt( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral less than weth debt' ); assertGe( @@ -871,8 +875,8 @@ contract SpokeRiskPremiumEdgeCasesTest is SpokeBase { // Now risk premium should equal collateral risk of dai since debt is fully covered by it assertGe( - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), - _getValue(spoke2, _wethReserveId(spoke2), borrowAmount), + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount), + _convertAmountToValue(spoke2, _wethReserveId(spoke2), borrowAmount), 'Bob dai collateral greater than weth debt' ); assertEq( diff --git a/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol index 9c05d47d0..a17ed10bf 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.Scenario.t.sol @@ -160,8 +160,16 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Now since debt has grown, weth supply is not enough to cover debt, hence rp changes // usdx is enough to cover remaining debt - uint256 daiDebtValue = _getDebtValue(spoke1, reservesIds.dai, accruedDaiDebt + daiPremiumDebt); - uint256 usdxSupplyValue = _getValue(spoke1, reservesIds.usdx, vars.usdxSupplyAmount); + uint256 daiDebtValue = _convertAmountToValue( + spoke1, + reservesIds.dai, + accruedDaiDebt + daiPremiumDebt + ); + uint256 usdxSupplyValue = _convertAmountToValue( + spoke1, + reservesIds.usdx, + vars.usdxSupplyAmount + ); assertLt(daiDebtValue, usdxSupplyValue); vars.expectedUserRiskPremium = _calculateExpectedUserRP(spoke1, alice); @@ -571,10 +579,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { uint16 usdxCollateralRisk, uint40[3] memory timeSkip ) public { - bobDaiAction = _boundUserBorrowAction(bobDaiAction); - bobUsdxAction = _boundUserBorrowAction(bobUsdxAction); - aliceDaiAction = _boundUserBorrowAction(aliceDaiAction); - aliceUsdxAction = _boundUserBorrowAction(aliceUsdxAction); + bobDaiAction = _boundUserBorrowAction(bobDaiAction, MAX_SUPPLY_AMOUNT_DAI); + bobUsdxAction = _boundUserBorrowAction(bobUsdxAction, MAX_SUPPLY_AMOUNT_USDX); + aliceDaiAction = _boundUserBorrowAction(aliceDaiAction, MAX_SUPPLY_AMOUNT_DAI); + aliceUsdxAction = _boundUserBorrowAction(aliceUsdxAction, MAX_SUPPLY_AMOUNT_USDX); daiCollateralRisk = bound(daiCollateralRisk, 0, MAX_COLLATERAL_RISK_BPS).toUint16(); usdxCollateralRisk = bound(usdxCollateralRisk, 0, MAX_COLLATERAL_RISK_BPS).toUint16(); @@ -904,10 +912,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { ) public { skipTime = bound(skipTime, 1, MAX_SKIP_TIME).toUint40(); - daiAmounts.supplyAmount = bound(daiAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethAmounts.supplyAmount = bound(wethAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxAmounts.supplyAmount = bound(usdxAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcAmounts.supplyAmount = bound(wbtcAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiAmounts.supplyAmount = bound(daiAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + wethAmounts.supplyAmount = bound(wethAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + usdxAmounts.supplyAmount = bound(usdxAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + wbtcAmounts.supplyAmount = bound(wbtcAmounts.supplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); daiAmounts.borrowAmount = bound(daiAmounts.borrowAmount, 0, daiAmounts.supplyAmount / 2); wethAmounts.borrowAmount = bound(wethAmounts.borrowAmount, 0, wethAmounts.supplyAmount / 2); @@ -916,15 +924,15 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { // Ensure supplied value is at least double borrowed value to pass hf checks vm.assume( - _getValue(spoke1, _daiReserveId(spoke1), daiAmounts.supplyAmount) + - _getValue(spoke1, _wethReserveId(spoke1), wethAmounts.supplyAmount) + - _getValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.supplyAmount) + - _getValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.supplyAmount) >= + _convertAmountToValue(spoke1, _daiReserveId(spoke1), daiAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.supplyAmount) + + _convertAmountToValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.supplyAmount) >= 2 * - (_getValue(spoke1, _daiReserveId(spoke1), daiAmounts.borrowAmount) + - _getValue(spoke1, _wethReserveId(spoke1), wethAmounts.borrowAmount) + - _getValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.borrowAmount) + - _getValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.borrowAmount)) + (_convertAmountToValue(spoke1, _daiReserveId(spoke1), daiAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _wethReserveId(spoke1), wethAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _usdxReserveId(spoke1), usdxAmounts.borrowAmount) + + _convertAmountToValue(spoke1, _wbtcReserveId(spoke1), wbtcAmounts.borrowAmount)) ); // Bob supplies and draws all assets on spoke1 @@ -973,9 +981,10 @@ contract SpokeRiskPremiumScenarioTest is SpokeBase { } function _boundUserBorrowAction( - UserBorrowAction memory action + UserBorrowAction memory action, + uint256 maxSupplyAmount ) internal pure returns (UserBorrowAction memory) { - action.supplyAmount = bound(action.supplyAmount, 2, MAX_SUPPLY_AMOUNT / 2); + action.supplyAmount = bound(action.supplyAmount, 2, maxSupplyAmount / 2); action.borrowAmount = bound(action.borrowAmount, 1, action.supplyAmount / 2); return action; } diff --git a/tests/unit/Spoke/Spoke.RiskPremium.t.sol b/tests/unit/Spoke/Spoke.RiskPremium.t.sol index b0f84feb7..abc84ce55 100644 --- a/tests/unit/Spoke/Spoke.RiskPremium.t.sol +++ b/tests/unit/Spoke/Spoke.RiskPremium.t.sol @@ -86,7 +86,7 @@ contract SpokeRiskPremiumTest is SpokeBase { function test_getUserRiskPremium_fuzz_single_reserve_collateral_borrowed_amount( uint256 borrowAmount ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); + borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); ReserveInfoLocal memory daiInfo; daiInfo.reserveId = _daiReserveId(spoke1); @@ -111,8 +111,8 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint256 additionalSupplyAmount ) public { - borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT / 2); - additionalSupplyAmount = bound(additionalSupplyAmount, 1, MAX_SUPPLY_AMOUNT); + borrowAmount = bound(borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 2); + additionalSupplyAmount = bound(additionalSupplyAmount, 1, MAX_SUPPLY_AMOUNT_USDX); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory usdxInfo; @@ -172,11 +172,15 @@ contract SpokeRiskPremiumTest is SpokeBase { _mockReservePrice(spoke2, _usdzReserveId(spoke2), 100000e8); // Check that debt has outgrown collateral - uint256 collateralValue = _getValue(spoke2, _wbtcReserveId(spoke2), wbtcSupplyAmount) + - _getValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount) + - _getValue(spoke2, _usdxReserveId(spoke2), usdxSupplyAmount) + - _getValue(spoke2, _wethReserveId(spoke2), wethSupplyAmount); - uint256 debtValue = _getValue(spoke2, _usdzReserveId(spoke2), borrowAmount); + uint256 collateralValue = _convertAmountToValue( + spoke2, + _wbtcReserveId(spoke2), + wbtcSupplyAmount + ) + + _convertAmountToValue(spoke2, _daiReserveId(spoke2), daiSupplyAmount) + + _convertAmountToValue(spoke2, _usdxReserveId(spoke2), usdxSupplyAmount) + + _convertAmountToValue(spoke2, _wethReserveId(spoke2), wethSupplyAmount); + uint256 debtValue = _convertAmountToValue(spoke2, _usdzReserveId(spoke2), borrowAmount); assertGt(debtValue, collateralValue, 'debt outgrows collateral'); assertFalse(_isHealthy(spoke2, bob)); @@ -252,9 +256,9 @@ contract SpokeRiskPremiumTest is SpokeBase { // Weth is enough to cover the total debt assertGe( - _getValue(spoke1, wethInfo.reserveId, wethInfo.supplyAmount), - _getValue(spoke1, daiInfo.reserveId, daiInfo.borrowAmount) + - _getValue(spoke1, usdxInfo.reserveId, usdxInfo.borrowAmount), + _convertAmountToValue(spoke1, wethInfo.reserveId, wethInfo.supplyAmount), + _convertAmountToValue(spoke1, daiInfo.reserveId, daiInfo.borrowAmount) + + _convertAmountToValue(spoke1, usdxInfo.reserveId, usdxInfo.borrowAmount), 'weth supply covers debt' ); uint256 expectedUserRiskPremium = wethInfo.collateralRisk; @@ -300,8 +304,8 @@ contract SpokeRiskPremiumTest is SpokeBase { // usdz is enough to cover the total debt assertGe( - _getValue(spoke2, usdzInfo.reserveId, usdzInfo.supplyAmount), - _getValue(spoke2, daiInfo.reserveId, daiInfo.borrowAmount), + _convertAmountToValue(spoke2, usdzInfo.reserveId, usdzInfo.supplyAmount), + _convertAmountToValue(spoke2, daiInfo.reserveId, daiInfo.borrowAmount), 'usdz supply covers debt' ); @@ -402,9 +406,9 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 usdxSupplyAmount, uint256 wethBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_WETH / 2; + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); wethBorrowAmount = bound(wethBorrowAmount, 0, totalBorrowAmount); @@ -418,7 +422,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wethInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wethInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WETH; // Borrow all value in weth wethInfo.borrowAmount = wethBorrowAmount; @@ -460,11 +464,10 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wethSupplyAmount, uint256 wbtcBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcBorrowAmount = bound(wbtcBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -479,7 +482,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; wbtcInfo.borrowAmount = wbtcBorrowAmount; @@ -526,14 +529,10 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wbtcSupplyAmount, uint256 borrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - - borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); + wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory usdxInfo; @@ -553,7 +552,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wbtcInfo.supplyAmount = wbtcSupplyAmount; // Borrow all value in usdz - usdzInfo.borrowAmount = borrowAmount; + usdzInfo.borrowAmount = bound(borrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); daiInfo.collateralRisk = _getCollateralRisk(spoke2, daiInfo.reserveId); wethInfo.collateralRisk = _getCollateralRisk(spoke2, wethInfo.reserveId); @@ -561,7 +560,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wbtcInfo.collateralRisk = _getCollateralRisk(spoke2, wbtcInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -584,7 +583,7 @@ contract SpokeRiskPremiumTest is SpokeBase { } // Bob supply usdz into spoke2 - Utils.supplyCollateral(spoke2, usdzInfo.reserveId, bob, MAX_SUPPLY_AMOUNT, bob); + Utils.supplyCollateral(spoke2, usdzInfo.reserveId, bob, MAX_SUPPLY_AMOUNT_USDZ, bob); // Bob draw usdz if (usdzInfo.borrowAmount > 0) { @@ -608,7 +607,7 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint256 newUsdxPrice ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_USDZ / 2; newUsdxPrice = bound(newUsdxPrice, 1, 1e16); @@ -635,7 +634,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; wbtcInfo.supplyAmount = wbtcSupplyAmount; - usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT_USDZ; // Borrow all value in usdz usdzInfo.borrowAmount = borrowAmount; @@ -647,7 +646,7 @@ contract SpokeRiskPremiumTest is SpokeBase { usdzInfo.collateralRisk = _getCollateralRisk(spoke2, usdzInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -703,15 +702,15 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 borrowAmount, uint24 newCrValue ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; + uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT_USDZ / 2; // Bound collateral risk to below usdz so reserve is still used in rp calc newCrValue = bound(newCrValue, 0, 99_99).toUint24(); - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX); + wbtcSupplyAmount = bound(wbtcSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WBTC); borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); @@ -731,7 +730,7 @@ contract SpokeRiskPremiumTest is SpokeBase { wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; wbtcInfo.supplyAmount = wbtcSupplyAmount; - usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + usdzInfo.supplyAmount = MAX_SUPPLY_AMOUNT_USDZ; // Borrow all value in usdz usdzInfo.borrowAmount = borrowAmount; @@ -743,7 +742,7 @@ contract SpokeRiskPremiumTest is SpokeBase { usdzInfo.collateralRisk = _getCollateralRisk(spoke2, usdzInfo.reserveId); // Handle supplying max of both dai and usdz - deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT * 2); + deal(address(tokenList.dai), bob, MAX_SUPPLY_AMOUNT_DAI * 2); // Bob supply wbtc into spoke2 if (wbtcInfo.supplyAmount > 0) { @@ -894,12 +893,9 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 usdxSupplyAmount, uint256 borrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - - borrowAmount = bound(borrowAmount, 0, totalBorrowAmount); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -914,9 +910,9 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; - wbtcInfo.borrowAmount = borrowAmount; + wbtcInfo.borrowAmount = bound(borrowAmount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); daiInfo.collateralRisk = _getCollateralRisk(spoke3, daiInfo.reserveId); wethInfo.collateralRisk = _getCollateralRisk(spoke3, wethInfo.reserveId); @@ -1002,13 +998,12 @@ contract SpokeRiskPremiumTest is SpokeBase { uint256 wbtcBorrowamount, uint256 wethBorrowAmount ) public { - uint256 totalBorrowAmount = MAX_SUPPLY_AMOUNT / 2; - daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT); - usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT); + daiSupplyAmount = bound(daiSupplyAmount, 0, MAX_SUPPLY_AMOUNT_DAI / 2); + wethSupplyAmount = bound(wethSupplyAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); + usdxSupplyAmount = bound(usdxSupplyAmount, 0, MAX_SUPPLY_AMOUNT_USDX / 2); - wbtcBorrowamount = bound(wbtcBorrowamount, 0, totalBorrowAmount); - wethBorrowAmount = bound(wethBorrowAmount, 0, totalBorrowAmount); + wbtcBorrowamount = bound(wbtcBorrowamount, 0, MAX_SUPPLY_AMOUNT_WBTC / 2); + wethBorrowAmount = bound(wethBorrowAmount, 0, MAX_SUPPLY_AMOUNT_WETH / 2); ReserveInfoLocal memory daiInfo; ReserveInfoLocal memory wethInfo; @@ -1023,7 +1018,7 @@ contract SpokeRiskPremiumTest is SpokeBase { daiInfo.supplyAmount = daiSupplyAmount; wethInfo.supplyAmount = wethSupplyAmount; usdxInfo.supplyAmount = usdxSupplyAmount; - wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT; + wbtcInfo.supplyAmount = MAX_SUPPLY_AMOUNT_WBTC; wbtcInfo.borrowAmount = wbtcBorrowamount; wethInfo.borrowAmount = wethBorrowAmount; @@ -1051,8 +1046,12 @@ contract SpokeRiskPremiumTest is SpokeBase { Utils.supplyCollateral(spoke3, wbtcInfo.reserveId, bob, wbtcInfo.supplyAmount, bob); // Alice supply remaining weth into spoke3 - if (MAX_SUPPLY_AMOUNT - wethInfo.supplyAmount > 0) { - _openSupplyPosition(spoke3, wethInfo.reserveId, MAX_SUPPLY_AMOUNT - wethInfo.supplyAmount); + if (MAX_SUPPLY_AMOUNT_WETH - wethInfo.supplyAmount > 0) { + _openSupplyPosition( + spoke3, + wethInfo.reserveId, + MAX_SUPPLY_AMOUNT_WETH - wethInfo.supplyAmount + ); } // Bob draw wbtc diff --git a/tests/unit/Spoke/Spoke.UserAccountData.t.sol b/tests/unit/Spoke/Spoke.UserAccountData.t.sol index cf28a88a8..be54f9409 100644 --- a/tests/unit/Spoke/Spoke.UserAccountData.t.sol +++ b/tests/unit/Spoke/Spoke.UserAccountData.t.sol @@ -50,7 +50,7 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, @@ -84,7 +84,7 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, @@ -118,7 +118,7 @@ contract SpokeUserAccountDataTest is SpokeBase { true, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.96e18, healthFactor: 1.28e18, riskPremium: 10_00, @@ -139,7 +139,8 @@ contract SpokeUserAccountDataTest is SpokeBase { // Supplied Assets: 1 WETH // Debt: 0.3 + 0.15 + 0.05 = 0.5 WETH = 0.5 * $2000 = $1000 // Health Factor: ($100 * 0.96 + $5000 * 0.5) / $1000 = 2.596 - // Avg Collateral Factor: (0.96 * $100 + 0.5 * $5000) / ($100 + $5000) = 0.509019608 + // Total Adjusted Collateral Value: 0.96 * $100 + 0.5 * $5000 = 2596 + // Avg Collateral Factor: $2596 / ($100 + $5000) = 0.509019607843137254 // Risk Premium: (0.1 * $100 + 0.15 * $900) / $1000 = 0.145 // Supplied Collaterals Count: 2 // Borrowed Reserves Count: 1 @@ -160,8 +161,8 @@ contract SpokeUserAccountDataTest is SpokeBase { true, ISpoke.UserAccountData({ totalCollateralValue: 5100e26, - totalDebtValue: 1000e26, - avgCollateralFactor: 0.509019608e18, + totalDebtValueRay: 1000e26 * WadRayMath.RAY, + avgCollateralFactor: 0.509019607843137254e18, healthFactor: 2.596e18, riskPremium: 14_50, activeCollateralCount: 2, @@ -202,7 +203,7 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 125e26, + totalDebtValueRay: 125e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.576e18, riskPremium: 10_00, @@ -242,7 +243,7 @@ contract SpokeUserAccountDataTest is SpokeBase { false, ISpoke.UserAccountData({ totalCollateralValue: 100e26, - totalDebtValue: 75e26, + totalDebtValueRay: 75e26 * WadRayMath.RAY, avgCollateralFactor: 0.72e18, healthFactor: 0.96e18, riskPremium: 10_00, @@ -262,23 +263,10 @@ contract SpokeUserAccountDataTest is SpokeBase { user, refreshConfig ); - assertApproxEq(userAccountData, expectedUserAccountData); + assertEq(userAccountData, expectedUserAccountData); } function _getLastReserveConfigKey(uint256 reserveId) internal view returns (uint24) { return spoke.getReserve(reserveId).dynamicConfigKey; } - - function assertApproxEq( - ISpoke.UserAccountData memory a, - ISpoke.UserAccountData memory b - ) internal pure { - assertEq(a.totalCollateralValue, b.totalCollateralValue, 'totalCollateralValue'); - assertEq(a.totalDebtValue, b.totalDebtValue, 'totalDebtValue'); - assertApproxEqAbs(a.avgCollateralFactor, b.avgCollateralFactor, 1e12, 'avgCollateralFactor'); - assertApproxEqAbs(a.healthFactor, b.healthFactor, 1e12, 'healthFactor'); - assertApproxEqAbs(a.riskPremium, b.riskPremium, 1, 'riskPremium'); - assertEq(a.activeCollateralCount, b.activeCollateralCount, 'activeCollateralCount'); - assertEq(a.borrowedCount, b.borrowedCount, 'borrowedCount'); - } } diff --git a/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol b/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol index 13ffc3917..1cf08b042 100644 --- a/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol +++ b/tests/unit/Spoke/Spoke.Withdraw.Scenario.t.sol @@ -130,9 +130,18 @@ contract SpokeWithdrawScenarioTest is SpokeBase { function test_withdraw_fuzz_all_liquidity_with_interest_multi_user( MultiUserFuzzParams memory params ) public { - params.reserveId = bound(params.reserveId, 0, spokeInfo[spoke1].MAX_ALLOWED_ASSET_ID); - params.aliceAmount = bound(params.aliceAmount, 1, MAX_SUPPLY_AMOUNT - 1); - params.bobAmount = bound(params.bobAmount, 1, MAX_SUPPLY_AMOUNT - params.aliceAmount); + params.reserveId = bound(params.reserveId, 0, spoke1.getReserveCount() - 1); + vm.assume(params.reserveId != _wbtcReserveId(spoke1)); + params.aliceAmount = bound( + params.aliceAmount, + 1, + _calculateMaxSupplyAmount(spoke1, params.reserveId) - 1 + ); + params.bobAmount = bound( + params.bobAmount, + 1, + _calculateMaxSupplyAmount(spoke1, params.reserveId) - params.aliceAmount + ); params.skipTime[0] = bound(params.skipTime[0], 0, MAX_SKIP_TIME); params.skipTime[1] = bound(params.skipTime[1], 0, MAX_SKIP_TIME); params.borrowAmount = bound( @@ -168,7 +177,15 @@ contract SpokeWithdrawScenarioTest is SpokeBase { spoke: spoke1, reserveId: _wbtcReserveId(spoke1), caller: carol, - amount: params.borrowAmount, // highest value asset so that it is enough collateral + amount: _max( + 1, + _convertAssetAmount( + spoke1, + params.reserveId, + params.borrowAmount * 4, + _wbtcReserveId(spoke1) + ) + ), // highest value asset so that it is enough collateral onBehalfOf: carol }); Utils.borrow({ diff --git a/tests/unit/Spoke/Spoke.Withdraw.t.sol b/tests/unit/Spoke/Spoke.Withdraw.t.sol index be2180539..92cc368f7 100644 --- a/tests/unit/Spoke/Spoke.Withdraw.t.sol +++ b/tests/unit/Spoke/Spoke.Withdraw.t.sol @@ -220,7 +220,7 @@ contract SpokeWithdrawTest is SpokeBase { } function test_withdraw_fuzz_suppliedAmount(uint256 supplyAmount) public { - supplyAmount = bound(supplyAmount, 1, MAX_SUPPLY_AMOUNT); + supplyAmount = bound(supplyAmount, 1, MAX_SUPPLY_AMOUNT_DAI); Utils.supply({ spoke: spoke1, reserveId: _daiReserveId(spoke1), @@ -300,7 +300,7 @@ contract SpokeWithdrawTest is SpokeBase { } function test_withdraw_fuzz_all_with_interest(uint256 supplyAmount, uint256 borrowAmount) public { - supplyAmount = bound(supplyAmount, 2, MAX_SUPPLY_AMOUNT); + supplyAmount = bound(supplyAmount, 2, MAX_SUPPLY_AMOUNT_DAI); borrowAmount = bound(borrowAmount, 1, supplyAmount / 2); Utils.supplyCollateral({ @@ -569,7 +569,7 @@ contract SpokeWithdrawTest is SpokeBase { params.borrowReserveSupplyAmount = bound( params.borrowReserveSupplyAmount, 2, - MAX_SUPPLY_AMOUNT + _calculateMaxSupplyAmount(spoke1, params.reserveId) ); params.borrowAmount = bound(params.borrowAmount, 1, params.borrowReserveSupplyAmount / 2); params.rate = bound(params.rate, 1, MAX_BORROW_RATE); @@ -592,7 +592,7 @@ contract SpokeWithdrawTest is SpokeBase { TestState memory state; state.reserveId = params.reserveId; state.collateralReserveId = _wbtcReserveId(spoke1); - state.suppliedCollateralAmount = MAX_SUPPLY_AMOUNT; // ensure enough collateral + state.suppliedCollateralAmount = _calculateMaxSupplyAmount(spoke1, state.collateralReserveId); // ensure enough collateral state.borrowReserveSupplyAmount = params.borrowReserveSupplyAmount; state.borrowAmount = params.borrowAmount; state.rate = params.rate; @@ -852,7 +852,7 @@ contract SpokeWithdrawTest is SpokeBase { params.borrowReserveSupplyAmount = bound( params.borrowReserveSupplyAmount, 2, - MAX_SUPPLY_AMOUNT + _calculateMaxSupplyAmount(spoke1, params.reserveId) ); params.borrowAmount = bound(params.borrowAmount, 1, params.borrowReserveSupplyAmount / 2); params.rate = bound(params.rate, 1, MAX_BORROW_RATE); @@ -867,7 +867,7 @@ contract SpokeWithdrawTest is SpokeBase { TestState memory state; state.reserveId = params.reserveId; state.collateralReserveId = _wbtcReserveId(spoke1); - state.suppliedCollateralAmount = MAX_SUPPLY_AMOUNT; // ensure enough collateral + state.suppliedCollateralAmount = _calculateMaxSupplyAmount(spoke1, state.collateralReserveId); // ensure enough collateral state.borrowReserveSupplyAmount = params.borrowReserveSupplyAmount; state.borrowAmount = params.borrowAmount; state.rate = params.rate; @@ -1008,7 +1008,7 @@ contract SpokeWithdrawTest is SpokeBase { /// can increase due to rounding, with interest accrual should strictly increase function test_fuzz_withdraw_effect_on_ex_rates(uint256 amount, uint256 delay) public { delay = bound(delay, 1, MAX_SKIP_TIME); - amount = bound(amount, 2, MAX_SUPPLY_AMOUNT / 2); + amount = bound(amount, 2, MAX_SUPPLY_AMOUNT_DAI / 2); uint256 wethSupplyAmount = _calcMinimumCollAmount( spoke1, _wethReserveId(spoke1), diff --git a/tests/unit/Spoke/SpokeBase.t.sol b/tests/unit/Spoke/SpokeBase.t.sol index 40ecd908e..324fc2334 100644 --- a/tests/unit/Spoke/SpokeBase.t.sol +++ b/tests/unit/Spoke/SpokeBase.t.sol @@ -553,6 +553,14 @@ contract SpokeBase is Base { userDebt.totalDebt = userDebt.drawnDebt + userDebt.premiumDebt; } + function _getUserDrawnShares( + ISpoke spoke, + uint256 reserveId, + address user + ) internal view returns (uint256) { + return spoke.getUserPosition(reserveId, user).drawnShares; + } + function _getUserDebt( ISpoke spoke, uint256 reserveId, @@ -731,6 +739,20 @@ contract SpokeBase is Base { assertEq(abi.encode(a), abi.encode(b)); // sanity check } + function assertEq( + ISpoke.UserAccountData memory a, + ISpoke.UserAccountData memory b + ) internal pure { + assertEq(a.riskPremium, b.riskPremium, 'riskPremium'); + assertEq(a.avgCollateralFactor, b.avgCollateralFactor, 'avgCollateralFactor'); + assertEq(a.totalCollateralValue, b.totalCollateralValue, 'totalCollateralValue'); + assertEq(a.totalDebtValueRay, b.totalDebtValueRay, 'totalDebtValueRay'); + assertEq(a.healthFactor, b.healthFactor, 'healthFactor'); + assertEq(a.activeCollateralCount, b.activeCollateralCount, 'activeCollateralCount'); + assertEq(a.borrowedCount, b.borrowedCount, 'borrowedCount'); + assertEq(abi.encode(a), abi.encode(b)); // sanity check + } + function _assertUserRpUnchanged(ISpoke spoke, address user) internal view { uint256 riskPremiumPreview = spoke.getUserAccountData(user).riskPremium; uint256 riskPremiumStored = _getUserRpStored(spoke, user); @@ -759,19 +781,19 @@ contract SpokeBase is Base { return spoke.getUserLastRiskPremium(user); } - function _boundUserAction(UserAction memory action) internal pure returns (UserAction memory) { - action.borrowAmount = bound(action.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); + function _boundUserAction(UserAction memory action) internal view returns (UserAction memory) { + action.borrowAmount = bound(action.borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 8); action.repayAmount = bound(action.repayAmount, 1, UINT256_MAX); return action; } - function _bound(UserAssetInfo memory info) internal pure returns (UserAssetInfo memory) { + function _bound(UserAssetInfo memory info) internal view returns (UserAssetInfo memory) { // Bound borrow amounts - info.daiInfo.borrowAmount = bound(info.daiInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.wethInfo.borrowAmount = bound(info.wethInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.usdxInfo.borrowAmount = bound(info.usdxInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); - info.wbtcInfo.borrowAmount = bound(info.wbtcInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT / 8); + info.daiInfo.borrowAmount = bound(info.daiInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_DAI / 8); + info.wethInfo.borrowAmount = bound(info.wethInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_WETH / 8); + info.usdxInfo.borrowAmount = bound(info.usdxInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_USDX / 8); + info.wbtcInfo.borrowAmount = bound(info.wbtcInfo.borrowAmount, 1, MAX_SUPPLY_AMOUNT_WBTC / 8); // Bound repay amounts info.daiInfo.repayAmount = bound(info.daiInfo.repayAmount, 1, UINT256_MAX); @@ -818,10 +840,12 @@ contract SpokeBase is Base { // Find all reserves user has supplied, adding up total debt for (uint256 reserveId; reserveId < vars.reserveCount; ++reserveId) { - vars.totalDebtValue += _getDebtValue( + // totalDebtValue is scaled by RAY here, downscaled later + vars.totalDebtValue += _convertAmountToValue( spoke, reserveId, - spoke.getUserTotalDebt(reserveId, user) + spoke.getUserPosition(reserveId, user).drawnShares * _reserveDrawnIndex(spoke, reserveId) + + _calculatePremiumDebtRay(spoke, reserveId, user) ); if (_isUsingAsCollateral(spoke, reserveId, user)) { @@ -832,7 +856,7 @@ contract SpokeBase is Base { .getDynamicReserveConfig(reserveId, vars.dynamicConfigKey) .collateralFactor; - vars.collateralValue = _getValue( + vars.collateralValue = _convertAmountToValue( spoke, reserveId, spoke.getUserSuppliedAssets(reserveId, user) @@ -846,6 +870,8 @@ contract SpokeBase is Base { return 0; } + vars.totalDebtValue = vars.totalDebtValue.fromRayUp(); + // Gather up list of reserves as collateral to sort by collateral risk KeyValueList.List memory reserveCollateralRisk = KeyValueList.init(vars.activeCollateralCount); for (uint256 reserveId; reserveId < vars.reserveCount; ++reserveId) { @@ -862,7 +888,7 @@ contract SpokeBase is Base { // While user's normalized debt amount is non-zero, iterate through supplied reserves, and add up collateral risk while (vars.totalDebtValue > 0 && vars.idx < reserveCollateralRisk.length()) { (uint256 collateralRisk, uint256 reserveId) = reserveCollateralRisk.get(vars.idx); - vars.collateralValue = _getValue( + vars.collateralValue = _convertAmountToValue( spoke, reserveId, spoke.getUserSuppliedAssets(reserveId, user) @@ -1045,7 +1071,7 @@ contract SpokeBase is Base { } function _randomCollateralFactor(ISpoke spoke, uint256 reserveId) internal returns (uint16) { - return vm.randomUint(1, _collateralFactorUpperBound(spoke, reserveId)).toUint16(); + return vm.randomUint(10_00, _collateralFactorUpperBound(spoke, reserveId)).toUint16(); } /// @dev Returns the id of the reserve corresponding to the given Liquidity Hub asset id diff --git a/tests/unit/Spoke/TreasurySpoke.t.sol b/tests/unit/Spoke/TreasurySpoke.t.sol index fe085d72a..62263c032 100644 --- a/tests/unit/Spoke/TreasurySpoke.t.sol +++ b/tests/unit/Spoke/TreasurySpoke.t.sol @@ -189,9 +189,9 @@ contract TreasurySpokeTest is SpokeBase { uint256 amount, uint256 skipTime ) public { - amount = bound(amount, 1, MAX_SUPPLY_AMOUNT); - skipTime = bound(skipTime, 1, MAX_SKIP_TIME); reserveId = bound(reserveId, 0, spoke1.getReserveCount() - 1); + amount = bound(amount, 1, _calculateMaxSupplyAmount(spoke1, reserveId)); + skipTime = bound(skipTime, 1, MAX_SKIP_TIME); uint256 assetId = spoke1.getReserve(reserveId).assetId; updateLiquidityFee(hub1, spoke1.getReserve(reserveId).assetId, 100_00); diff --git a/tests/unit/WadRayMath.t.sol b/tests/unit/WadRayMath.t.sol index 74ac566fd..bba71b533 100644 --- a/tests/unit/WadRayMath.t.sol +++ b/tests/unit/WadRayMath.t.sol @@ -13,6 +13,7 @@ contract WadRayMathDifferentialTests is Test { } function test_constants() public view { + assertEq(w.WAD_DECIMALS(), 18, 'wad decimals'); assertEq(w.WAD(), 1e18, 'wad'); assertEq(w.RAY(), 1e27, 'ray'); assertEq(w.PERCENTAGE_FACTOR(), 1e4, 'percentage factor'); @@ -180,6 +181,10 @@ contract WadRayMathDifferentialTests is Test { assertEq(w.fromWadDown(a), a / w.WAD()); } + function test_fromRayDown_fuzz(uint256 a) public view { + assertEq(w.fromRayDown(a), a / w.RAY()); + } + function test_fromRayUp_fuzz(uint256 a) public view { assertEq( w.fromRayUp(a), @@ -225,15 +230,14 @@ contract WadRayMathDifferentialTests is Test { uint256 b; bool safetyCheck; unchecked { - b = a * w.WAD(); - safetyCheck = b / w.WAD() == a; + b = a * (w.WAD() / w.PERCENTAGE_FACTOR()); + safetyCheck = (a == 0 || type(uint256).max / a >= w.WAD() / w.PERCENTAGE_FACTOR()); } if (!safetyCheck) { vm.expectRevert(); w.bpsToWad(a); } else { - assertEq(w.bpsToWad(a), (a * w.WAD()) / 100_00); - assertEq(w.bpsToWad(a), b / 100_00); + assertEq(w.bpsToWad(a), b); } } @@ -241,15 +245,14 @@ contract WadRayMathDifferentialTests is Test { uint256 b; bool safetyCheck; unchecked { - b = a * w.RAY(); - safetyCheck = b / w.RAY() == a; + b = a * (w.RAY() / w.PERCENTAGE_FACTOR()); + safetyCheck = (a == 0 || type(uint256).max / a >= w.RAY() / w.PERCENTAGE_FACTOR()); } if (!safetyCheck) { vm.expectRevert(); w.bpsToRay(a); } else { - assertEq(w.bpsToRay(a), (a * w.RAY()) / 100_00); - assertEq(w.bpsToRay(a), b / 100_00); + assertEq(w.bpsToRay(a), b); } } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol index 06f8c2f43..fa04c45c3 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol @@ -41,7 +41,11 @@ contract LiquidationLogicBaseTest is SpokeBase { function _bound( LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory params ) internal virtual returns (LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) { - uint256 totalDebtValue = bound(params.totalDebtValue, 1, MAX_SUPPLY_IN_BASE_CURRENCY); + uint256 totalDebtValueRay = bound( + params.totalDebtValueRay, + 1, + MAX_SUPPLY_IN_BASE_CURRENCY * WadRayMath.RAY + ); uint256 liquidationBonus = bound( params.liquidationBonus, @@ -68,7 +72,7 @@ contract LiquidationLogicBaseTest is SpokeBase { return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: totalDebtValue, + totalDebtValueRay: totalDebtValueRay, debtAssetUnit: debtAssetUnit, debtAssetPrice: debtAssetPrice, collateralFactor: collateralFactor, @@ -86,20 +90,33 @@ contract LiquidationLogicBaseTest is SpokeBase { ); uint256 debtToCover = bound(params.debtToCover, 0, MAX_SUPPLY_AMOUNT); - uint256 debtReserveBalance = bound( - params.debtReserveBalance, + uint256 drawnIndex = bound(params.drawnIndex, MIN_DRAWN_INDEX, MAX_DRAWN_INDEX); + uint256 drawnShares = bound( + params.drawnShares, + 1, + _convertValueToAmount( + MAX_SUPPLY_AMOUNT, + debtToTargetParams.debtAssetPrice, + debtToTargetParams.debtAssetUnit + ) + ); + uint256 premiumDebtRay = bound( + params.premiumDebtRay, 0, _convertValueToAmount( - debtToTargetParams.totalDebtValue, + MAX_SUPPLY_AMOUNT, debtToTargetParams.debtAssetPrice, debtToTargetParams.debtAssetUnit - ).min(MAX_SUPPLY_AMOUNT) + ) ); return LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: debtReserveBalance, - totalDebtValue: debtToTargetParams.totalDebtValue, + drawnShares: drawnShares, + premiumDebtRay: premiumDebtRay, + drawnIndex: drawnIndex, + totalDebtValueRay: debtToTargetParams.totalDebtValueRay, + debtAssetDecimals: Math.log10(debtToTargetParams.debtAssetUnit), debtAssetUnit: debtToTargetParams.debtAssetUnit, debtAssetPrice: debtToTargetParams.debtAssetPrice, debtToCover: debtToCover, @@ -114,19 +131,97 @@ contract LiquidationLogicBaseTest is SpokeBase { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) internal virtual returns (LiquidationLogic.CalculateDebtToLiquidateParams memory) { params = _bound(params); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + // bound price such that 1 drawn share is worth less than DUST_LIQUIDATION_THRESHOLD + params.debtAssetPrice = bound( + params.debtAssetPrice, + 1, + _convertDecimals( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + 18, + params.debtAssetDecimals, + false + ).rayDivDown(params.drawnIndex) + ); + + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - params.debtReserveBalance = bound( - params.debtReserveBalance, - debtToTarget.min(params.debtToCover) + 1, - debtToTarget.min(params.debtToCover) + + + uint256 debtRayToLiquidate = debtRayToTarget.min( + _max(_min(type(uint256).max.fromRayDown(), params.debtToCover.toRay()), params.drawnIndex) - + params.drawnIndex // debtToCover acts as an upperbound + ); + uint256 debtRay = vm.randomUint( + debtRayToLiquidate + 1, + debtRayToLiquidate + _convertValueToAmount( LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, params.debtAssetPrice, params.debtAssetUnit - ) + ).toRay() ); + + params.drawnShares = bound(params.drawnShares, 0, debtRay / params.drawnIndex); + vm.assume(params.drawnShares > 0); + params.premiumDebtRay = debtRay - params.drawnShares * params.drawnIndex; + + return params; + } + + function _bound( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) internal virtual returns (LiquidationLogic.CalculateCollateralToLiquidateParams memory) { + params.collateralReserveHub = hub1; + params.collateralReserveAssetId = bound( + params.collateralReserveAssetId, + 0, + IHub(address(params.collateralReserveHub)).getAssetCount() - 1 + ); + params.collateralAssetUnit = + 10 ** + bound(params.collateralAssetUnit, MIN_TOKEN_DECIMALS_SUPPORTED, MAX_TOKEN_DECIMALS_SUPPORTED); + params.collateralAssetPrice = bound(params.collateralAssetPrice, 1, MAX_ASSET_PRICE); + params.drawnIndex = bound(params.drawnIndex, MIN_DRAWN_INDEX, MAX_DRAWN_INDEX); + params.drawnSharesToLiquidate = bound( + params.drawnSharesToLiquidate, + 0, + MAX_SUPPLY_AMOUNT / params.drawnIndex + ); + params.premiumDebtRayToLiquidate = bound( + params.premiumDebtRayToLiquidate, + 0, + MAX_SUPPLY_AMOUNT - params.drawnSharesToLiquidate * params.drawnIndex + ); + params.debtAssetUnit = + 10 ** bound(params.debtAssetUnit, MIN_TOKEN_DECIMALS_SUPPORTED, MAX_TOKEN_DECIMALS_SUPPORTED); + uint256 debtRayToLiquidate = params.drawnSharesToLiquidate * params.drawnIndex + + params.premiumDebtRayToLiquidate; + params.debtAssetPrice = bound( + params.debtAssetPrice, + 1, + MAX_SUPPLY_AMOUNT / + _max(1, _convertAmountToValue(debtRayToLiquidate.fromRayUp(), 1, params.debtAssetUnit)) + ); + params.liquidationBonus = bound( + params.liquidationBonus, + MIN_LIQUIDATION_BONUS, + MAX_LIQUIDATION_BONUS + ); + + uint256 hubAddedShares = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 hubAddedAssets = vm.randomUint( + hubAddedShares, + MAX_SUPPLY_AMOUNT.min( + MAX_SUPPLY_PRICE * (hubAddedShares + SharesMath.VIRTUAL_SHARES) - SharesMath.VIRTUAL_ASSETS + ) + ); + _mockSupplySharePrice( + IHub(address(params.collateralReserveHub)), + params.collateralReserveAssetId, + hubAddedAssets, + hubAddedShares + ); + return params; } @@ -145,18 +240,20 @@ contract LiquidationLogicBaseTest is SpokeBase { params.maxLiquidationBonus ); - params.debtAssetUnit = bound( - params.debtAssetUnit, - 10 ** MIN_TOKEN_DECIMALS_SUPPORTED, - 10 ** MAX_TOKEN_DECIMALS_SUPPORTED + params.debtAssetDecimals = bound( + params.debtAssetDecimals, + MIN_TOKEN_DECIMALS_SUPPORTED, + MAX_TOKEN_DECIMALS_SUPPORTED ); LiquidationLogic.CalculateDebtToLiquidateParams memory debtToLiquidateParams = _getCalculateDebtToLiquidateParams(params); debtToLiquidateParams = _bound(debtToLiquidateParams); - params.debtReserveBalance = debtToLiquidateParams.debtReserveBalance; - params.totalDebtValue = debtToLiquidateParams.totalDebtValue; + params.drawnShares = debtToLiquidateParams.drawnShares; + params.premiumDebtRay = debtToLiquidateParams.premiumDebtRay; + params.drawnIndex = debtToLiquidateParams.drawnIndex; + params.totalDebtValueRay = debtToLiquidateParams.totalDebtValueRay; params.debtAssetPrice = debtToLiquidateParams.debtAssetPrice; params.debtToCover = debtToLiquidateParams.debtToCover; params.healthFactor = debtToLiquidateParams.healthFactor; @@ -164,13 +261,33 @@ contract LiquidationLogicBaseTest is SpokeBase { params.collateralFactor = debtToLiquidateParams.collateralFactor; params.collateralAssetPrice = bound(params.collateralAssetPrice, 1, MAX_ASSET_PRICE); - params.collateralAssetUnit = bound( - params.collateralAssetUnit, - 10 ** MIN_TOKEN_DECIMALS_SUPPORTED, - 10 ** MAX_TOKEN_DECIMALS_SUPPORTED + params.collateralAssetDecimals = bound( + params.collateralAssetDecimals, + MIN_TOKEN_DECIMALS_SUPPORTED, + MAX_TOKEN_DECIMALS_SUPPORTED ); params.liquidationFee = bound(params.liquidationFee, 0, PercentageMath.PERCENTAGE_FACTOR); - params.collateralReserveBalance = bound(params.collateralReserveBalance, 0, MAX_SUPPLY_AMOUNT); + + params.suppliedShares = bound(params.suppliedShares, 0, MAX_SUPPLY_AMOUNT); + uint256 hubAddedShares = vm.randomUint(params.suppliedShares, MAX_SUPPLY_AMOUNT); + uint256 hubAddedAssets = vm.randomUint( + hubAddedShares, + MAX_SUPPLY_AMOUNT.min( + MAX_SUPPLY_PRICE * (hubAddedShares + SharesMath.VIRTUAL_SHARES) - SharesMath.VIRTUAL_ASSETS + ) + ); + params.collateralReserveHub = hub1; + params.collateralReserveAssetId = bound( + params.collateralReserveAssetId, + 0, + IHub(address(params.collateralReserveHub)).getAssetCount() - 1 + ); + _mockSupplySharePrice( + IHub(address(params.collateralReserveHub)), + params.collateralReserveAssetId, + hubAddedAssets, + hubAddedShares + ); return params; } @@ -183,9 +300,11 @@ contract LiquidationLogicBaseTest is SpokeBase { memory debtToLiquidateParams = _getCalculateDebtToLiquidateParams(params); debtToLiquidateParams = _boundWithDustAdjustment(debtToLiquidateParams); - params.debtReserveBalance = debtToLiquidateParams.debtReserveBalance; - params.totalDebtValue = debtToLiquidateParams.totalDebtValue; - params.debtAssetUnit = debtToLiquidateParams.debtAssetUnit; + params.drawnShares = debtToLiquidateParams.drawnShares; + params.premiumDebtRay = debtToLiquidateParams.premiumDebtRay; + params.drawnIndex = debtToLiquidateParams.drawnIndex; + params.totalDebtValueRay = debtToLiquidateParams.totalDebtValueRay; + params.debtAssetDecimals = debtToLiquidateParams.debtAssetDecimals; params.debtAssetPrice = debtToLiquidateParams.debtAssetPrice; params.debtToCover = debtToLiquidateParams.debtToCover; params.collateralFactor = debtToLiquidateParams.collateralFactor; @@ -200,7 +319,7 @@ contract LiquidationLogicBaseTest is SpokeBase { ) internal pure returns (LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory) { return LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: params.totalDebtValue, + totalDebtValueRay: params.totalDebtValueRay, debtAssetUnit: params.debtAssetUnit, debtAssetPrice: params.debtAssetPrice, collateralFactor: params.collateralFactor, @@ -221,9 +340,12 @@ contract LiquidationLogicBaseTest is SpokeBase { }); return LiquidationLogic.CalculateDebtToLiquidateParams({ - debtReserveBalance: params.debtReserveBalance, - totalDebtValue: params.totalDebtValue, - debtAssetUnit: params.debtAssetUnit, + drawnShares: params.drawnShares, + premiumDebtRay: params.premiumDebtRay, + drawnIndex: params.drawnIndex, + totalDebtValueRay: params.totalDebtValueRay, + debtAssetDecimals: params.debtAssetDecimals, + debtAssetUnit: 10 ** params.debtAssetDecimals, debtAssetPrice: params.debtAssetPrice, debtToCover: params.debtToCover, collateralFactor: params.collateralFactor, diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol new file mode 100644 index 000000000..28a650c4a --- /dev/null +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.CollateralToLiquidate.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; + +contract LiquidationLogicCollateralToLiquidateTest is LiquidationLogicBaseTest { + using WadRayMath for uint256; + + function test_calculateCollateralToLiquidate_fuzz( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) public { + params = _bound(params); + + uint256 collateralAmountToLiquidate = _calculateCollateralAmountToLiquidate(params); + uint256 expectedCollateralSharesToLiquidate = _calculateCollateralSharesToLiquidate( + params, + collateralAmountToLiquidate + ); + + vm.expectCall( + address(params.collateralReserveHub), + abi.encodeWithSelector( + IHubBase.previewAddByAssets.selector, + params.collateralReserveAssetId, + collateralAmountToLiquidate + ), + 1 + ); + + uint256 collateralSharesToLiquidate = liquidationLogicWrapper.calculateCollateralToLiquidate( + params + ); + + assertEq(collateralSharesToLiquidate, expectedCollateralSharesToLiquidate); + } + + function test_calculateCollateralAmountToLiquidate() public { + // drawnIndex = 1.5, supply share price = 1.25 + // debt asset: weth, 18 decimals, price = 1000 + // collateral asset: usdx, 6 decimals, price = 0.98 + // liquidation bonus = 105% + // drawn shares to liquidate = 3 + // premium debt ray to liquidate = 0.4 + // total debt to liquidate = 3 * 1.5 + 0.4 = 4.9 + // debt to collateral = 4.9 * 1000 / 0.98 = 5000 + // collateral with bonus = 5000 * 105% = 5250 + // collateral shares to liquidate = 5250 / 1.25 = 4200 + _mockSupplySharePrice(hub1, usdxAssetId, 12_500.25e6, 10_000e6); + vm.expectCall( + address(hub1), + abi.encodeWithSelector(IHubBase.previewAddByAssets.selector, usdxAssetId, 5250e6), + 1 + ); + uint256 collateralSharesToLiquidate = liquidationLogicWrapper.calculateCollateralToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams({ + collateralReserveHub: hub1, + collateralReserveAssetId: usdxAssetId, + collateralAssetUnit: 10 ** tokenList.usdx.decimals(), + collateralAssetPrice: 0.98e8, + drawnSharesToLiquidate: 3e18, + premiumDebtRayToLiquidate: 0.4e18 * 1e27, + drawnIndex: 1.5e27, + debtAssetUnit: 10 ** tokenList.weth.decimals(), + debtAssetPrice: 1000e8, + liquidationBonus: 105_00 + }) + ); + assertEq(collateralSharesToLiquidate, 4200e6); + } + + function _calculateCollateralAmountToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params + ) internal pure returns (uint256) { + uint256 debtRayToLiquidate = params.drawnSharesToLiquidate * params.drawnIndex + + params.premiumDebtRayToLiquidate; + + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * params.collateralAssetUnit * params.liquidationBonus, + params.debtAssetUnit * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor + ); + + return collateralToLiquidate; + } + + function _calculateCollateralSharesToLiquidate( + LiquidationLogic.CalculateCollateralToLiquidateParams memory params, + uint256 collateralAmountToLiquidate + ) internal view returns (uint256) { + return + params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralAmountToLiquidate + ); + } +} diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol index ab5266f74..fdcf696bc 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToLiquidate.t.sol @@ -15,23 +15,56 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { ) public { params = _bound(params); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - uint256 rawDebtToLiquidate = params.debtReserveBalance.min(params.debtToCover).min( - debtToTarget + uint256 rawPremiumDebtRayToLiquidate = debtRayToTarget.fromRayUp().toRay().min( + params.premiumDebtRay ); + if (params.debtToCover <= rawPremiumDebtRayToLiquidate.fromRayDown()) { + rawPremiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToTarget = (rawPremiumDebtRayToLiquidate == params.premiumDebtRay && + rawPremiumDebtRayToLiquidate < debtRayToTarget) + ? (debtRayToTarget - rawPremiumDebtRayToLiquidate).divUp(params.drawnIndex) + : 0; + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - rawPremiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + uint256 rawDrawnSharesToLiquidate = drawnSharesToTarget.min(drawnSharesToCover).min( + params.drawnShares + ); + + uint256 assetsRequired = _calculateDebtAssetsToRestore({ + drawnSharesToLiquidate: rawDrawnSharesToLiquidate, + premiumDebtRayToLiquidate: rawPremiumDebtRayToLiquidate, + drawnIndex: params.drawnIndex + }); + assertLe(assetsRequired, params.debtToCover, 'assets required'); + + uint256 debtRayRemaining = (params.drawnShares - rawDrawnSharesToLiquidate) * + params.drawnIndex + + params.premiumDebtRay - + rawPremiumDebtRayToLiquidate; bool leavesDebtDust = _convertAmountToValue( - params.debtReserveBalance - rawDebtToLiquidate, + debtRayRemaining.fromRayDown(), params.debtAssetPrice, params.debtAssetUnit ) < LiquidationLogic.DUST_LIQUIDATION_THRESHOLD; + + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); if (leavesDebtDust) { - assertEq(debtToLiquidate, params.debtReserveBalance); + assertEq(drawnSharesToLiquidate, params.drawnShares); + assertEq(premiumDebtRayToLiquidate, params.premiumDebtRay); } else { - assertEq(debtToLiquidate, rawDebtToLiquidate); + assertEq(drawnSharesToLiquidate, rawDrawnSharesToLiquidate); + assertEq(premiumDebtRayToLiquidate, rawPremiumDebtRayToLiquidate); } } @@ -40,23 +73,44 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) public { params = _bound(params); - params.debtAssetUnit = 10 ** bound(params.debtAssetUnit, 1, 5); + params.debtAssetDecimals = bound(params.debtAssetDecimals, 1, 5); + params.debtAssetUnit = 10 ** params.debtAssetDecimals; params.debtAssetPrice = bound( params.debtAssetPrice, LiquidationLogic.DUST_LIQUIDATION_THRESHOLD.fromWadDown() * params.debtAssetUnit, MAX_ASSET_PRICE ); - uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( + uint256 debtRayToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( _getDebtToTargetHealthFactorParams(params) ); - params.debtReserveBalance = bound( - params.debtReserveBalance, - debtToTarget.min(params.debtToCover), + + uint256 rawPremiumDebtRayToLiquidate = debtRayToTarget.fromRayUp().toRay().min( + params.premiumDebtRay + ); + if (params.debtToCover <= rawPremiumDebtRayToLiquidate.fromRayDown()) { + rawPremiumDebtRayToLiquidate = params.debtToCover.toRay(); + } + + uint256 drawnSharesToTarget = (rawPremiumDebtRayToLiquidate == params.premiumDebtRay && + rawPremiumDebtRayToLiquidate < debtRayToTarget) + ? (debtRayToTarget - rawPremiumDebtRayToLiquidate).divUp(params.drawnIndex) + : 0; + uint256 drawnSharesToCover = Math.mulDiv( + params.debtToCover - rawPremiumDebtRayToLiquidate.fromRayUp(), + WadRayMath.RAY, + params.drawnIndex, + Math.Rounding.Floor + ); + params.drawnShares = bound( + params.drawnShares, + drawnSharesToTarget.min(drawnSharesToCover), MAX_SUPPLY_AMOUNT ); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - assertEq(debtToLiquidate, debtToTarget.min(params.debtToCover)); + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); + assertEq(drawnSharesToLiquidate, drawnSharesToTarget.min(drawnSharesToCover)); + assertEq(premiumDebtRayToLiquidate, rawPremiumDebtRayToLiquidate); } /// function returns total reserve debt if dust is left @@ -64,7 +118,9 @@ contract LiquidationLogicDebtToLiquidateTest is LiquidationLogicBaseTest { LiquidationLogic.CalculateDebtToLiquidateParams memory params ) public { params = _boundWithDustAdjustment(params); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate(params); - assertEq(debtToLiquidate, params.debtReserveBalance); + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(params); + assertEq(drawnSharesToLiquidate, params.drawnShares); + assertEq(premiumDebtRayToLiquidate, params.premiumDebtRay); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol index e621ac44f..53d63d028 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.DebtToTargetHealthFactor.t.sol @@ -57,7 +57,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes uint256 assetUnit = assetUnitList[i]; uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetPrice: 1e8, debtAssetUnit: assetUnit, collateralFactor: 50_00, @@ -69,7 +69,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes // liquidationPenalty = 1.5 * 0.5 = 0.75 // debtToTarget = $10000 * (1.25 - 0.8) / (1.25 - 0.75) / $1 = 9000 - assertEq(debtToTarget, 9000 * assetUnit); + assertEq(debtToTarget, 9000 * assetUnit * WadRayMath.RAY); } } @@ -78,7 +78,7 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes uint256 assetUnit = assetUnitList[i]; uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor( LiquidationLogic.CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetUnit: assetUnit, debtAssetPrice: 2000e8, collateralFactor: 50_00, @@ -90,14 +90,14 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes // liquidationPenalty = 1.5 * 0.5 = 0.75 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.75) / $2000 = 4 - assertEq(debtToTarget, 4 * assetUnit); + assertEq(debtToTarget, 4 * assetUnit * WadRayMath.RAY); } } function test_calculateDebtToTargetHealthFactor_PrecisionLoss() public view { LiquidationLogic.CalculateDebtToTargetHealthFactorParams memory params = LiquidationLogic .CalculateDebtToTargetHealthFactorParams({ - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, debtAssetUnit: 1, debtAssetPrice: 333e8, collateralFactor: 50_00, @@ -106,14 +106,14 @@ contract LiquidationLogicDebtToTargetHealthFactorTest is LiquidationLogicBaseTes targetHealthFactor: 1e18 }); uint256 debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 25); + assertEq(debtToTarget, 24.024024024024024024024024025e27); params.debtAssetUnit = 1e6; debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 24.024025e6); + assertEq(debtToTarget, 24.024024024024024024024024024024025e33); params.debtAssetUnit = 1e18; debtToTarget = liquidationLogicWrapper.calculateDebtToTargetHealthFactor(params); - assertEq(debtToTarget, 24.024024024024024025e18); + assertEq(debtToTarget, 24.024024024024024024024024024024024024024024025e45); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol new file mode 100644 index 000000000..c5006ea43 --- /dev/null +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ExecuteLiquidation.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; + +contract LiquidationLogicExecuteLiquidationTest is LiquidationLogicBaseTest { + using SafeCast for *; + using WadRayMath for uint256; + using ReserveFlagsMap for ReserveFlags; + + uint256 usdxReserveId; + uint256 wethReserveId; + + LiquidationLogic.ExecuteLiquidationParams params; + + // drawn index is 1.05, supply share price is 1.25 + // variable liquidation bonus is max: 120% + // liquidation penalty: 1.2 * 0.5 = 0.6 + // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 + // max debt to liquidate = min(2.5, 4.4 * 1.05 + 0.4, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.4 + // drawnSharesToLiquidate = (2.5 - 0.4) / 1.05 = 2 + // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 + function setUp() public override { + super.setUp(); + IHub collateralReserveHub = hub1; + _mockSupplySharePrice(collateralReserveHub, usdxAssetId, 12_500.25e6, 10_000e6); + (IHub debtReserveHub, ) = hub2Fixture(); + _mockInterestRateBps(debtReserveHub.getAsset(wethAssetId).irStrategy, 5_00); + + // Mock params + usdxReserveId = _usdxReserveId(spoke1); + wethReserveId = _wethReserveId(spoke1); + params = LiquidationLogic.ExecuteLiquidationParams({ + collateralHub: collateralReserveHub, + collateralAssetId: usdxAssetId, + collateralAssetDecimals: 6, + collateralReserveId: usdxReserveId, + collateralReserveFlags: ReserveFlagsMap.create(false, false, false, true), + collateralDynConfig: ISpoke.DynamicReserveConfig({ + maxLiquidationBonus: 120_00, + collateralFactor: 50_00, + liquidationFee: 10_00 + }), + debtHub: debtReserveHub, + debtAssetId: wethAssetId, + debtAssetDecimals: 18, + debtUnderlying: address(tokenList.weth), + debtReserveId: wethReserveId, + debtReserveFlags: ReserveFlagsMap.create(false, false, false, false), + liquidationConfig: ISpoke.LiquidationConfig({ + targetHealthFactor: 1e18, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00 + }), + oracle: address(oracle1), + user: makeAddr('user'), + debtToCover: 3e18, + healthFactor: 0.8e18, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + liquidator: makeAddr('liquidator'), + activeCollateralCount: 1, + borrowedCount: 1, + receiveShares: false + }); + + // Mock storage + liquidationLogicWrapper.setBorrower(params.user); + liquidationLogicWrapper.setLiquidator(params.liquidator); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_000e6); + liquidationLogicWrapper.setDebtPositionDrawnShares(4.4e18); + liquidationLogicWrapper.setDebtPositionPremiumShares(1e18); + liquidationLogicWrapper.setDebtPositionPremiumOffsetRay((0.65e18 * WadRayMath.RAY).toInt256()); + liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); + liquidationLogicWrapper.setBorrowerBorrowingStatus(wethReserveId, true); + + // Set liquidationLogicWrapper as a spoke + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK + }); + vm.startPrank(HUB_ADMIN); + collateralReserveHub.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); + debtReserveHub.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); + vm.stopPrank(); + + // Collateral hub: Add liquidity + address tempUser = makeUser(); + deal(address(tokenList.usdx), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add(hub1, usdxAssetId, address(liquidationLogicWrapper), MAX_SUPPLY_AMOUNT, tempUser); + + // Debt hub: Add liquidity, remove liquidity, refresh premium and skip time to accrue both drawn and premium debt + deal(address(tokenList.weth), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + MAX_SUPPLY_AMOUNT, + tempUser + ); + Utils.draw( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + tempUser, + MAX_SUPPLY_AMOUNT + ); + vm.startPrank(address(liquidationLogicWrapper)); + debtReserveHub.refreshPremium( + wethAssetId, + _getExpectedPremiumDelta({ + hub: debtReserveHub, + assetId: wethAssetId, + oldPremiumShares: 0, + oldPremiumOffsetRay: 0, + drawnShares: 1e6 * 1e18, // risk premium is 100% + riskPremium: 100_00, + restoredPremiumRay: 0 + }) + ); + vm.stopPrank(); + skip(365 days); + (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = debtReserveHub.getSpokeOwed( + wethAssetId, + address(liquidationLogicWrapper) + ); + assertGt(spokeDrawnOwed, 10000e18); + assertGt(spokePremiumOwed, 10000e18); + + // Mint tokens to liquidator and approve spoke + deal(address(tokenList.weth), params.liquidator, spokeDrawnOwed + spokePremiumOwed); + Utils.approve( + ISpoke(address(liquidationLogicWrapper)), + address(tokenList.weth), + params.liquidator, + spokeDrawnOwed + spokePremiumOwed + ); + } + + function test_executeLiquidation() public { + uint256 initialCollateralReserveBalance = tokenList.usdx.balanceOf( + address(params.collateralHub) + ); + uint256 initialDebtReserveBalance = tokenList.weth.balanceOf(address(params.debtHub)); + uint256 initialLiquidatorWethBalance = tokenList.weth.balanceOf(address(params.liquidator)); + + ISpoke.UserPosition memory debtPosition = liquidationLogicWrapper.getDebtPosition(params.user); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4800e6)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4720e6)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.remove, (usdxAssetId, 5900e6, params.liquidator)), + 1 + ); + + vm.expectCall( + address(params.collateralHub), + abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, 80e6)), + 1 + ); + + vm.expectCall( + address(params.debtHub), + abi.encodeCall( + IHubBase.restore, + ( + wethAssetId, + 2.1e18, + _getExpectedPremiumDelta({ + hub: IHub(address(params.debtHub)), + assetId: wethAssetId, + oldPremiumShares: debtPosition.premiumShares, + oldPremiumOffsetRay: debtPosition.premiumOffsetRay, + drawnShares: 0, + riskPremium: 0, + restoredPremiumRay: 0.4e18 * WadRayMath.RAY + }) + ) + ), + 1 + ); + + bool hasDeficit = liquidationLogicWrapper.executeLiquidation(params); + assertEq(hasDeficit, false); + + assertEq( + tokenList.usdx.balanceOf(address(params.collateralHub)), + initialCollateralReserveBalance - 5900e6 + ); + assertEq(tokenList.usdx.balanceOf(address(params.liquidator)), 5900e6); + assertApproxEqAbs( + params.collateralHub.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), + 80e6, + 1 + ); + + assertEq(tokenList.weth.balanceOf(address(params.debtHub)), initialDebtReserveBalance + 2.5e18); + assertEq( + tokenList.weth.balanceOf(address(params.liquidator)), + initialLiquidatorWethBalance - 2.5e18 + ); + } + + function test_executeLiquidation_revertsWith_InvalidDebtToCover() public { + params.debtToCover = 0; + vm.expectRevert(ISpoke.InvalidDebtToCover.selector); + liquidationLogicWrapper.executeLiquidation(params); + } + + function test_executeLiquidation_revertsWith_MustNotLeaveDust_Debt() public { + // debtToTarget doubles (from 2.5 to 5) + // debtToCover is 4.9, so 5.02 - 4.9 = 0.12 debt is left + params.totalDebtValueRay *= 2; + params.debtToCover = 4.9e18; + liquidationLogicWrapper.setCollateralPositionSuppliedShares( + liquidationLogicWrapper.getCollateralPosition(params.user).suppliedShares * 2 + ); + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + liquidationLogicWrapper.executeLiquidation(params); + } + + function test_executeLiquidation_revertsWith_MustNotLeaveDust_Collateral() public { + // collateral shares remaining is 5200 - 4800 = 400 + // this would leave collateral dust, hence collateral are increased + // new debt that needs to be liquidated is > 2.7, which is more than debtToCover (2.6) + liquidationLogicWrapper.setCollateralPositionSuppliedShares(5200e6); + params.debtToCover = 2.6e18; + vm.expectRevert(ISpoke.MustNotLeaveDust.selector); + liquidationLogicWrapper.executeLiquidation(params); + } +} diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol index 213d83d9b..9ad955e24 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateCollateral.t.sol @@ -3,11 +3,13 @@ pragma solidity ^0.8.0; import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; +import {HubBase} from 'tests/unit/Hub/HubBase.t.sol'; -contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { +contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest, HubBase { using SafeCast for uint256; - LiquidationLogic.LiquidateCollateralParams params; + address borrower; + address liquidator; IHub hub; ISpoke spoke; @@ -15,16 +17,13 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { uint256 assetId; uint256 userSuppliedShares; uint256 reserveId; - address borrower; - address liquidator; - ISpoke.Reserve initialReserve; ISpoke.UserPosition initialUserPosition; ISpoke.UserPosition initialLiquidatorPosition; IHub.SpokeData initialTreasurySpokeData; - function setUp() public override { - super.setUp(); + function setUp() public override(HubBase, LiquidationLogicBaseTest) { + LiquidationLogicBaseTest.setUp(); hub = hub1; spoke = ISpoke(address(liquidationLogicWrapper)); @@ -35,14 +34,10 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { borrower = makeAddr('borrower'); liquidator = makeAddr('liquidator'); - liquidationLogicWrapper.setCollateralReserveHub(hub); - liquidationLogicWrapper.setCollateralReserveAssetId(assetId); - liquidationLogicWrapper.setCollateralReserveId(reserveId); liquidationLogicWrapper.setBorrower(borrower); - liquidationLogicWrapper.setCollateralPositionSuppliedShares(userSuppliedShares); liquidationLogicWrapper.setLiquidator(liquidator); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(userSuppliedShares); - initialReserve = liquidationLogicWrapper.getCollateralReserve(); initialUserPosition = liquidationLogicWrapper.getCollateralPosition(borrower); initialLiquidatorPosition = liquidationLogicWrapper.getCollateralPosition(liquidator); initialTreasurySpokeData = hub.getSpoke(assetId, address(treasurySpoke)); @@ -58,164 +53,103 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { vm.prank(HUB_ADMIN); hub.addSpoke(assetId, address(spoke), spokeConfig); - address tempUser = makeUser(); - deal(address(asset), tempUser, MAX_SUPPLY_AMOUNT); - Utils.add(hub, assetId, address(spoke), MAX_SUPPLY_AMOUNT, tempUser); + // add and drawn liquidity to increase supply share price of assetId + deal(address(asset), alice, MAX_SUPPLY_AMOUNT * 2); + _addAndDrawLiquidity({ + hub: hub, + assetId: assetId, + addUser: alice, + addSpoke: address(spoke), + addAmount: userSuppliedShares * 3, + drawUser: alice, + drawSpoke: address(spoke), + drawAmount: userSuppliedShares, + skipTime: 365 days + }); } function test_liquidateCollateral_fuzz( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator + uint256 sharesToLiquidate, + uint256 sharesToLiquidator, + bool receiveShares ) public { - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: bound( - collateralToLiquidate, - 1, - hub.previewRemoveByShares(assetId, userSuppliedShares) - ), - collateralToLiquidator: 0, // populated below - liquidator: liquidator, - receiveShares: false - }); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: bound(sharesToLiquidate, 0, userSuppliedShares), + sharesToLiquidator: 0, // populated below + liquidator: liquidator, + receiveShares: receiveShares + }); + params.sharesToLiquidator = bound(sharesToLiquidator, 0, params.sharesToLiquidate); uint256 initialHubBalance = asset.balanceOf(address(hub)); + uint256 expectedAmountToLiquidator; + if (!params.receiveShares) { + expectedAmountToLiquidator = hub.previewRemoveByShares(assetId, params.sharesToLiquidator); + } + uint256 expectedAmountRemoved = hub.previewRemoveByShares(assetId, params.sharesToLiquidate); - uint256 sharesToLiquidate = _expectEventsAndCalls(params); - (, , bool isPositionEmpty) = liquidationLogicWrapper.liquidateCollateral(params); - - assertEq(liquidationLogicWrapper.getCollateralReserve(), initialReserve); - assertPosition( - liquidationLogicWrapper.getCollateralPosition(borrower), - initialUserPosition, - userSuppliedShares - sharesToLiquidate - ); + _expectCalls(params); + LiquidationLogic.LiquidateCollateralResult memory result = liquidationLogicWrapper + .liquidateCollateral(params); - assertEq(isPositionEmpty, userSuppliedShares == sharesToLiquidate); - assertEq(asset.balanceOf(address(hub)), initialHubBalance - params.collateralToLiquidator); - assertEq(asset.balanceOf(params.liquidator), params.collateralToLiquidator); - assertApproxEqAbs( - hub.getSpokeAddedShares(assetId, address(treasurySpoke)), - params.collateralToLiquidate - params.collateralToLiquidator, - 1 + assertEq(result.amountRemoved, expectedAmountRemoved, 'amountRemoved'); + assertEq( + result.isCollateralPositionEmpty, + userSuppliedShares == params.sharesToLiquidate, + 'isCollateralPositionEmpty' ); - } - - /// on receiveShares, sharesToLiquidator should round down - function test_liquidateCollateral_receiveShares_sharesToLiquidatorIsZero() public { - // increase reserve index to ensure sharesToLiquidator rounds to 0 while feeShares rounds up to 1 - _increaseReserveIndex(spoke1, reserveId); - // supply ex rate is between 1 and 2 - assertGt(hub.previewAddByShares(assetId, WadRayMath.RAY), WadRayMath.RAY); - assertLt(hub.previewAddByShares(assetId, WadRayMath.RAY), 2 * WadRayMath.RAY); - - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: 1, - collateralToLiquidator: 1, - liquidator: liquidator, - receiveShares: true - }); - - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint256 sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - uint256 feeShares = sharesToLiquidate - sharesToLiquidator; - - assertEq(sharesToLiquidate, 1); - assertEq(sharesToLiquidator, 0); - assertEq(feeShares, 1); - - _expectEventsAndCalls(params); - liquidationLogicWrapper.liquidateCollateral(params); - - // sharesToLiquidator should round to 0 and remain unchanged - assertPosition( - liquidationLogicWrapper.getCollateralPosition(params.liquidator), - initialLiquidatorPosition, - sharesToLiquidator - ); assertPosition( liquidationLogicWrapper.getCollateralPosition(borrower), initialUserPosition, - userSuppliedShares - sharesToLiquidate + userSuppliedShares - params.sharesToLiquidate ); - assertSpokePosition( - hub.getSpoke(assetId, address(treasurySpoke)), - initialTreasurySpokeData, - initialTreasurySpokeData.addedShares + (sharesToLiquidate - sharesToLiquidator).toUint120() - ); - } - - // on receiveShares, sharesToLiquidator should round down - function test_liquidateCollateral_fuzz_receiveShares_sharesToLiquidator( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator - ) public { - params = LiquidationLogic.LiquidateCollateralParams({ - collateralToLiquidate: bound( - collateralToLiquidate, - 1, - hub.previewRemoveByShares(assetId, 1e6) - ), - collateralToLiquidator: 0, // populated below - liquidator: liquidator, - receiveShares: true - }); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); - - // increase reserve index to ensure sharesToLiquidator rounds to 0 while feeShares rounds up to 1 - _increaseReserveIndex(spoke1, reserveId); - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, params.collateralToLiquidate); - uint256 sharesToLiquidator = hub.previewAddByAssets(assetId, params.collateralToLiquidator); - - _expectEventsAndCalls(params); - liquidationLogicWrapper.liquidateCollateral(params); - - // sharesToLiquidator should round to 0 and remain unchanged + assertEq(asset.balanceOf(params.liquidator), expectedAmountToLiquidator); assertPosition( liquidationLogicWrapper.getCollateralPosition(params.liquidator), initialLiquidatorPosition, - sharesToLiquidator + initialLiquidatorPosition.suppliedShares + + (params.receiveShares ? params.sharesToLiquidator : 0) ); - assertPosition( - liquidationLogicWrapper.getCollateralPosition(borrower), - initialUserPosition, - userSuppliedShares - sharesToLiquidate - ); - assertSpokePosition( - hub.getSpoke(assetId, address(treasurySpoke)), - initialTreasurySpokeData, - initialTreasurySpokeData.addedShares + (sharesToLiquidate - sharesToLiquidator).toUint120() + + assertEq(asset.balanceOf(address(hub)), initialHubBalance - expectedAmountToLiquidator); + assertEq( + hub.getSpokeAddedShares(assetId, address(treasurySpoke)), + params.sharesToLiquidate - params.sharesToLiquidator ); } - // hub.remove is skipped when collateralToLiquidator is 0 (otherwise it would revert) - function test_liquidateCollateral_fuzz_CollateralToLiquidatorIsZero( - uint256 collateralToLiquidate - ) public { - params.collateralToLiquidate = bound( - collateralToLiquidate, - 0, - hub.previewRemoveByShares(assetId, userSuppliedShares) - ); - params.collateralToLiquidator = 0; + // reverts with arithmetic underflow when updating user's supplied shares + function test_liquidateCollateral_revertsWith_ArithmeticUnderflow() public { + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: userSuppliedShares + 1, + sharesToLiquidator: userSuppliedShares + 1, + liquidator: liquidator, + receiveShares: false + }); - vm.expectCall(address(hub), abi.encodeWithSelector(IHubBase.remove.selector), 0); + vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateCollateral(params); } - // reverts with arithmetic underflow when updating user's supplied shares - function test_liquidateCollateral_fuzz_revertsWith_ArithmeticUnderflow( - uint256 collateralToLiquidate, - uint256 collateralToLiquidator - ) public { - params.collateralToLiquidate = bound( - collateralToLiquidate, - hub.previewRemoveByShares(assetId, userSuppliedShares) + 1, - MAX_SUPPLY_AMOUNT - ); - params.collateralToLiquidator = bound(collateralToLiquidator, 1, params.collateralToLiquidate); + // reverts with arithmetic underflow when computing fee shares + function test_liquidateCollateral_revertsWith_ArithmeticUnderflow_FeeShares() public { + LiquidationLogic.LiquidateCollateralParams memory params = LiquidationLogic + .LiquidateCollateralParams({ + hub: hub, + assetId: assetId, + sharesToLiquidate: userSuppliedShares, + sharesToLiquidator: userSuppliedShares + 1, + liquidator: liquidator, + receiveShares: false + }); vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateCollateral(params); @@ -230,51 +164,35 @@ contract LiquidationLogicLiquidateCollateralTest is LiquidationLogicBaseTest { assertEq(newPosition, initPosition); } - function assertSpokePosition( - IHub.SpokeData memory newSpokeData, - IHub.SpokeData memory initSpokeData, - uint256 newAddedShares - ) internal pure { - initSpokeData.addedShares = newAddedShares.toUint120(); - assertEq(newSpokeData, initSpokeData); - } + function _expectCalls(LiquidationLogic.LiquidateCollateralParams memory p) internal { + uint256 collateralToLiquidator = hub.previewRemoveByShares(assetId, p.sharesToLiquidator); - function _expectEventsAndCalls( - LiquidationLogic.LiquidateCollateralParams memory p - ) internal returns (uint256) { - uint256 sharesToLiquidate = hub.previewRemoveByAssets(assetId, p.collateralToLiquidate); - uint256 sharesToLiquidator = p.receiveShares - ? hub.previewAddByAssets(assetId, p.collateralToLiquidator) - : hub.previewRemoveByAssets(assetId, p.collateralToLiquidator); - uint256 sharesToPayFee = sharesToLiquidate - sharesToLiquidator; - - if (p.collateralToLiquidator > 0 && p.receiveShares) { - vm.expectCall( - address(hub), - abi.encodeCall(IHubBase.previewAddByAssets, (assetId, p.collateralToLiquidator)), - 1 - ); - } - if (p.collateralToLiquidator > 0 && !p.receiveShares) { - vm.expectCall( - address(hub), - abi.encodeCall(IHubBase.remove, (assetId, p.collateralToLiquidator, p.liquidator)), - 1 - ); - } vm.expectCall( address(hub), - abi.encodeCall(IHubBase.previewRemoveByAssets, (assetId, p.collateralToLiquidate)), + abi.encodeCall(IHubBase.previewRemoveByShares, (assetId, p.sharesToLiquidate)), 1 ); - if (sharesToPayFee > 0) { + + if (p.sharesToLiquidator != p.sharesToLiquidate) { + // otherwise already checked above vm.expectCall( address(hub), - abi.encodeCall(IHubBase.payFeeShares, (assetId, sharesToPayFee)), - 1 + abi.encodeCall(IHubBase.previewRemoveByShares, (assetId, p.sharesToLiquidator)), + (p.sharesToLiquidator > 0 && !p.receiveShares) ? 1 : 0 ); } - return sharesToLiquidate; + vm.expectCall( + address(hub), + abi.encodeCall(IHubBase.remove, (assetId, collateralToLiquidator, p.liquidator)), + (p.sharesToLiquidator > 0 && !p.receiveShares) ? 1 : 0 + ); + + uint256 sharesToPayFee = p.sharesToLiquidate - p.sharesToLiquidator; + vm.expectCall( + address(hub), + abi.encodeCall(IHubBase.payFeeShares, (assetId, sharesToPayFee)), + sharesToPayFee > 0 ? 1 : 0 + ); } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol index 942e51ca6..15cb5549b 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateDebt.t.sol @@ -8,8 +8,6 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { using SafeCast for *; using WadRayMath for uint256; - LiquidationLogic.LiquidateDebtParams params; - IHub internal hub; ISpoke internal spoke; IERC20 internal asset; @@ -34,10 +32,6 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // Set initial storage values liquidationLogicWrapper.setBorrower(user); liquidationLogicWrapper.setLiquidator(liquidator); - liquidationLogicWrapper.setDebtReserveId(reserveId); - liquidationLogicWrapper.setDebtReserveHub(hub); - liquidationLogicWrapper.setDebtReserveAssetId(assetId); - liquidationLogicWrapper.setDebtReserveUnderlying(address(asset)); liquidationLogicWrapper.setBorrowerBorrowingStatus(reserveId, true); // Add liquidation logic wrapper as a spoke @@ -81,8 +75,9 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { } function test_liquidateDebt_fuzz(uint256) public { - (uint256 spokeDrawnOwed, ) = hub.getSpokeOwed(assetId, address(spoke)); IHub.SpokeData memory spokeData = hub.getSpoke(assetId, address(spoke)); + uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 spokePremiumOwedRay = _calculatePremiumDebtRay( hub, assetId, @@ -90,67 +85,94 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { spokeData.premiumOffsetRay ); - uint256 drawnDebt = vm.randomUint(0, spokeDrawnOwed); + uint256 drawnShares = vm.randomUint(1, spokeData.drawnShares); uint256 premiumDebtRay = vm.randomUint(0, spokePremiumOwedRay); - vm.assume(drawnDebt * WadRayMath.RAY + premiumDebtRay > 0); - - uint256 debtToLiquidate = vm.randomUint(1, drawnDebt + premiumDebtRay.fromRayUp()); - (uint256 drawnToLiquidate, uint256 premiumToLiquidateRay) = _calculateLiquidationAmounts( - premiumDebtRay, - debtToLiquidate - ); - - ISpoke.UserPosition memory initialPosition = _updateStorage(drawnDebt, premiumDebtRay); + ISpoke.UserPosition memory initialPosition = _updateStorage(drawnShares, premiumDebtRay); + + uint256 drawnSharesToLiquidate; + uint256 premiumDebtRayToLiquidate; + bool liquidatePremiumOnly = vm.randomBool(); + if (liquidatePremiumOnly) { + premiumDebtRayToLiquidate = vm.randomUint(1, premiumDebtRay); + } else { + premiumDebtRayToLiquidate = premiumDebtRay; + drawnSharesToLiquidate = vm.randomUint(1, drawnShares); + } uint256 initialHubBalance = asset.balanceOf(address(hub)); uint256 initialLiquidatorBalance = asset.balanceOf(liquidator); - expectCall( - initialPosition.premiumShares, - initialPosition.premiumOffsetRay, - drawnToLiquidate, - premiumToLiquidateRay - ); + expectCall({ + drawnIndex: drawnIndex, + premiumShares: initialPosition.premiumShares, + premiumOffsetRay: initialPosition.premiumOffsetRay, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate + }); - (uint256 drawnSharesLiquidated, , bool isPositionEmpty) = liquidationLogicWrapper.liquidateDebt( - LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, - drawnIndex: hub.getAssetDrawnIndex(assetId), - liquidator: liquidator - }) + LiquidationLogic.LiquidateDebtResult memory liquidateDebtResult = liquidationLogicWrapper + .liquidateDebt( + LiquidationLogic.LiquidateDebtParams({ + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate, + drawnIndex: drawnIndex, + liquidator: liquidator + }) + ); + + uint256 amountRestored = drawnSharesToLiquidate.rayMulUp(drawnIndex) + + premiumDebtRayToLiquidate.fromRayUp(); + assertEq(liquidateDebtResult.amountRestored, amountRestored); + assertEq(liquidateDebtResult.isDebtPositionEmpty, drawnShares == drawnSharesToLiquidate); + assertEq( + liquidationLogicWrapper.getBorrowerBorrowingStatus(reserveId), + !liquidateDebtResult.isDebtPositionEmpty ); - - assertEq(drawnSharesLiquidated, hub.previewRestoreByAssets(assetId, drawnToLiquidate)); - assertEq(isPositionEmpty, debtToLiquidate == drawnDebt + premiumDebtRay.fromRayUp()); - assertEq(liquidationLogicWrapper.getBorrowerBorrowingStatus(reserveId), !isPositionEmpty); assertPosition( liquidationLogicWrapper.getDebtPosition(user), initialPosition, - drawnSharesLiquidated, - premiumToLiquidateRay + drawnSharesToLiquidate, + premiumDebtRayToLiquidate ); - assertEq(asset.balanceOf(address(hub)), initialHubBalance + debtToLiquidate); - assertEq(asset.balanceOf(liquidator), initialLiquidatorBalance - debtToLiquidate); + assertEq(asset.balanceOf(address(hub)), initialHubBalance + amountRestored); + assertEq(asset.balanceOf(liquidator), initialLiquidatorBalance - amountRestored); } // reverts with arithmetic underflow if more debt is liquidated than the position has function test_liquidateDebt_revertsWith_ArithmeticUnderflow() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidate = drawnDebt + premiumDebtRay.fromRayUp() + 1; + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); vm.expectRevert(stdError.arithmeticError); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: 0, + premiumDebtRayToLiquidate: premiumDebtRay + 1, + drawnIndex: drawnIndex, + liquidator: liquidator + }) + ); + + vm.expectRevert(stdError.arithmeticError); + liquidationLogicWrapper.liquidateDebt( + LiquidationLogic.LiquidateDebtParams({ + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares + 1, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -159,22 +181,24 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // reverts when spoke does not have enough allowance from liquidator function test_liquidateDebt_revertsWith_InsufficientAllowance() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidateRay = drawnDebt * WadRayMath.RAY + premiumDebtRay; - uint256 debtToLiquidate = debtToLiquidateRay.fromRayUp(); - Utils.approve(spoke, address(asset), liquidator, debtToLiquidate - 1); + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 amountToRestore = drawnShares.rayMulUp(drawnIndex) + premiumDebtRay.fromRayUp(); + Utils.approve(spoke, address(asset), liquidator, amountToRestore - 1); + vm.expectRevert(); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -183,22 +207,24 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { // reverts when liquidator does not have enough balance function test_liquidateDebt_revertsWith_InsufficientBalance() public { - uint256 drawnDebt = 100e18; + uint256 drawnShares = 100e18; uint256 premiumDebtRay = 10e18 * WadRayMath.RAY; - _updateStorage(drawnDebt, premiumDebtRay); - - uint256 debtToLiquidateRay = drawnDebt * WadRayMath.RAY + premiumDebtRay; - uint256 debtToLiquidate = debtToLiquidateRay.fromRayUp(); - deal(address(asset), liquidator, debtToLiquidate - 1); + _updateStorage(drawnShares, premiumDebtRay); uint256 drawnIndex = hub.getAssetDrawnIndex(assetId); + uint256 amountToRestore = drawnShares.rayMulUp(drawnIndex) + premiumDebtRay.fromRayUp(); + deal(address(asset), liquidator, amountToRestore - 1); + vm.expectRevert(); liquidationLogicWrapper.liquidateDebt( LiquidationLogic.LiquidateDebtParams({ - debtReserveId: reserveId, - debtToLiquidate: debtToLiquidate, - premiumDebtRay: premiumDebtRay, + hub: hub, + assetId: assetId, + underlying: address(asset), + reserveId: reserveId, + drawnSharesToLiquidate: drawnShares, + premiumDebtRayToLiquidate: premiumDebtRay, drawnIndex: drawnIndex, liquidator: liquidator }) @@ -206,10 +232,11 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { } function expectCall( + uint256 drawnIndex, uint256 premiumShares, int256 premiumOffsetRay, - uint256 drawnToLiquidate, - uint256 premiumToLiquidateRay + uint256 drawnSharesToLiquidate, + uint256 premiumDebtRayToLiquidate ) internal { IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta({ hub: hub, @@ -218,26 +245,27 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { oldPremiumOffsetRay: premiumOffsetRay, drawnShares: 0, riskPremium: 0, - restoredPremiumRay: premiumToLiquidateRay + restoredPremiumRay: premiumDebtRayToLiquidate }); + vm.expectCall( address(hub), - abi.encodeCall(IHubBase.restore, (assetId, drawnToLiquidate, premiumDelta)) + abi.encodeCall( + IHubBase.restore, + (assetId, drawnSharesToLiquidate.rayMulUp(drawnIndex), premiumDelta) + ) ); } function _updateStorage( - uint256 drawnDebt, + uint256 drawnShares, uint256 premiumDebtRay ) internal returns (ISpoke.UserPosition memory) { - liquidationLogicWrapper.setDebtPositionDrawnShares( - hub.previewRestoreByAssets(assetId, drawnDebt) - ); - uint256 premiumDebtShares = hub.previewDrawByAssets(assetId, premiumDebtRay.fromRayUp()); - liquidationLogicWrapper.setDebtPositionPremiumShares(premiumDebtShares); + liquidationLogicWrapper.setDebtPositionDrawnShares(drawnShares); + uint256 premiumShares = hub.previewDrawByAssets(assetId, premiumDebtRay.fromRayUp()); + liquidationLogicWrapper.setDebtPositionPremiumShares(premiumShares); liquidationLogicWrapper.setDebtPositionPremiumOffsetRay( - _calculatePremiumAssetsRay(hub, assetId, premiumDebtShares).toInt256() - - premiumDebtRay.toInt256() + _calculatePremiumAssetsRay(hub, assetId, premiumShares).toInt256() - premiumDebtRay.toInt256() ); return liquidationLogicWrapper.getDebtPosition(user); @@ -247,7 +275,7 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { ISpoke.UserPosition memory newPosition, ISpoke.UserPosition memory initialPosition, uint256 drawnSharesLiquidated, - uint256 premiumToLiquidateRay + uint256 premiumDebtRayLiquidated ) internal view { uint256 premiumDebtRay = _calculatePremiumDebtRay( hub, @@ -257,19 +285,9 @@ contract LiquidationLogicLiquidateDebtTest is LiquidationLogicBaseTest { ); initialPosition.drawnShares -= drawnSharesLiquidated.toUint120(); initialPosition.premiumShares = 0; - initialPosition.premiumOffsetRay = -(premiumDebtRay - premiumToLiquidateRay) + initialPosition.premiumOffsetRay = -(premiumDebtRay - premiumDebtRayLiquidated) .toInt256() .toInt200(); assertEq(newPosition, initialPosition); } - - function _calculateLiquidationAmounts( - uint256 premiumDebtRay, - uint256 debtToLiquidate - ) internal pure returns (uint256, uint256) { - uint256 debtToLiquidateRay = debtToLiquidate.toRay(); - uint256 premiumToLiquidateRay = _min(premiumDebtRay, debtToLiquidateRay); - uint256 drawnToLiquidate = debtToLiquidate - premiumToLiquidateRay.fromRayUp(); - return (drawnToLiquidate, premiumToLiquidateRay); - } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol index a24e77bb6..2c39ae074 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidateUser.t.sol @@ -7,30 +7,32 @@ import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { using SafeCast for *; using WadRayMath for uint256; - - IHub hub2; + using ReserveFlagsMap for ReserveFlags; uint256 usdxReserveId; uint256 wethReserveId; - - ISpoke.LiquidationConfig liquidationConfig; - ISpoke.DynamicReserveConfig dynamicCollateralConfig; + IHub collateralReserveHub; + IHub debtReserveHub; LiquidationLogic.LiquidateUserParams params; - // drawn index is 1.05 + // drawn index is 1.05, supply share price is 1.25 // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 4.4 * 1.05 + 0.4, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.4 + // drawnSharesToLiquidate = (2.5 - 0.4) / 1.05 = 2 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // bonus collateral = 6000 - 6000 / 120% = 1000 - // collateral fee = 1000 * 10% = 100 - // collateral to liquidator = 6000 - 100 = 5900 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 function setUp() public override { super.setUp(); - (hub2, ) = hub2Fixture(); - - _mockInterestRateBps(hub2.getAsset(wethAssetId).irStrategy, 5_00); + collateralReserveHub = hub1; + _mockSupplySharePrice(collateralReserveHub, usdxAssetId, 12_500.25e6, 10_000e6); + (debtReserveHub, ) = hub2Fixture(); + _mockInterestRateBps(debtReserveHub.getAsset(wethAssetId).irStrategy, 5_00); // Mock params usdxReserveId = _usdxReserveId(spoke1); @@ -40,68 +42,62 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { debtReserveId: wethReserveId, oracle: address(oracle1), user: makeAddr('user'), + liquidationConfig: ISpoke.LiquidationConfig({ + targetHealthFactor: 1e18, + healthFactorForMaxBonus: 0.8e18, + liquidationBonusFactor: 50_00 + }), debtToCover: 3e18, healthFactor: 0.8e18, - drawnDebt: 4.5e18, - premiumDebtRay: 0.5e18 * WadRayMath.RAY, - drawnIndex: 1.05e27, - totalDebtValue: 10_000e26, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, liquidator: makeAddr('liquidator'), activeCollateralCount: 1, borrowedCount: 1, receiveShares: false }); - // Set liquidationLogicWrapper as a spoke - IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ - active: true, - halted: false, - addCap: Constants.MAX_ALLOWED_SPOKE_CAP, - drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, - riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK - }); - vm.startPrank(HUB_ADMIN); - hub1.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); - hub2.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); - vm.stopPrank(); - - // set borrower + // Mock storage liquidationLogicWrapper.setBorrower(params.user); liquidationLogicWrapper.setLiquidator(params.liquidator); - - // Mock storage for collateral side - require(hub1.getAsset(usdxAssetId).underlying == address(tokenList.usdx)); liquidationLogicWrapper.setCollateralReserveId(usdxReserveId); - liquidationLogicWrapper.setCollateralReserveHub(hub1); - liquidationLogicWrapper.setCollateralReserveAssetId(usdxAssetId); + liquidationLogicWrapper.setCollateralReserveHub(collateralReserveHub); liquidationLogicWrapper.setCollateralReserveDecimals(6); - liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_200e6); - liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); - - // Mock storage for debt side - require(hub2.getAsset(wethAssetId).underlying == address(tokenList.weth)); + liquidationLogicWrapper.setCollateralReserveAssetId(usdxAssetId); + liquidationLogicWrapper.setCollateralReserveFlags( + ReserveFlagsMap.create(false, false, false, true) + ); + liquidationLogicWrapper.setDynamicCollateralConfig( + ISpoke.DynamicReserveConfig({ + maxLiquidationBonus: 120_00, + collateralFactor: 50_00, + liquidationFee: 10_00 + }) + ); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(10_000e6); liquidationLogicWrapper.setDebtReserveId(wethReserveId); - liquidationLogicWrapper.setDebtReserveHub(hub2); + liquidationLogicWrapper.setDebtReserveHub(debtReserveHub); + liquidationLogicWrapper.setDebtReserveDecimals(18); liquidationLogicWrapper.setDebtReserveAssetId(wethAssetId); liquidationLogicWrapper.setDebtReserveUnderlying(address(tokenList.weth)); - liquidationLogicWrapper.setDebtReserveDecimals(18); + liquidationLogicWrapper.setDebtReserveFlags(ReserveFlagsMap.create(false, false, false, false)); + liquidationLogicWrapper.setDebtPositionDrawnShares(4.4e18); + liquidationLogicWrapper.setDebtPositionPremiumShares(1e18); + liquidationLogicWrapper.setDebtPositionPremiumOffsetRay((0.65e18 * WadRayMath.RAY).toInt256()); + liquidationLogicWrapper.setBorrowerCollateralStatus(usdxReserveId, true); liquidationLogicWrapper.setBorrowerBorrowingStatus(wethReserveId, true); - // Mock storage for liquidation config - liquidationConfig = ISpoke.LiquidationConfig({ - healthFactorForMaxBonus: 0.8e18, - liquidationBonusFactor: 50_00, - targetHealthFactor: 1e18 - }); - updateStorage(liquidationConfig); - - // Mock storage for dynamic collateral config - dynamicCollateralConfig = ISpoke.DynamicReserveConfig({ - maxLiquidationBonus: 120_00, - collateralFactor: 50_00, - liquidationFee: 10_00 + // Set liquidationLogicWrapper as a spoke + IHub.SpokeConfig memory spokeConfig = IHub.SpokeConfig({ + active: true, + halted: false, + addCap: Constants.MAX_ALLOWED_SPOKE_CAP, + drawCap: Constants.MAX_ALLOWED_SPOKE_CAP, + riskPremiumThreshold: Constants.MAX_ALLOWED_COLLATERAL_RISK }); - updateStorage(dynamicCollateralConfig); + vm.startPrank(HUB_ADMIN); + collateralReserveHub.addSpoke(usdxAssetId, address(liquidationLogicWrapper), spokeConfig); + debtReserveHub.addSpoke(wethAssetId, address(liquidationLogicWrapper), spokeConfig); + vm.stopPrank(); // Collateral hub: Add liquidity address tempUser = makeUser(); @@ -110,13 +106,25 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { // Debt hub: Add liquidity, remove liquidity, refresh premium and skip time to accrue both drawn and premium debt deal(address(tokenList.weth), tempUser, MAX_SUPPLY_AMOUNT); - Utils.add(hub2, wethAssetId, address(liquidationLogicWrapper), MAX_SUPPLY_AMOUNT, tempUser); - Utils.draw(hub2, wethAssetId, address(liquidationLogicWrapper), tempUser, MAX_SUPPLY_AMOUNT); + Utils.add( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + MAX_SUPPLY_AMOUNT, + tempUser + ); + Utils.draw( + debtReserveHub, + wethAssetId, + address(liquidationLogicWrapper), + tempUser, + MAX_SUPPLY_AMOUNT + ); vm.startPrank(address(liquidationLogicWrapper)); - hub2.refreshPremium( + debtReserveHub.refreshPremium( wethAssetId, _getExpectedPremiumDelta({ - hub: hub2, + hub: debtReserveHub, assetId: wethAssetId, oldPremiumShares: 0, oldPremiumOffsetRay: 0, @@ -127,23 +135,13 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { ); vm.stopPrank(); skip(365 days); - (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = hub2.getSpokeOwed( + (uint256 spokeDrawnOwed, uint256 spokePremiumOwed) = debtReserveHub.getSpokeOwed( wethAssetId, address(liquidationLogicWrapper) ); assertGt(spokeDrawnOwed, 10000e18); assertGt(spokePremiumOwed, 10000e18); - // Mock user debt position - liquidationLogicWrapper.setDebtPositionDrawnShares( - hub2.previewRestoreByAssets(wethAssetId, params.drawnDebt) - ); - liquidationLogicWrapper.setDebtPositionPremiumShares(params.premiumDebtRay.fromRayUp()); - liquidationLogicWrapper.setDebtPositionPremiumOffsetRay( - _calculatePremiumAssetsRay(hub2, wethAssetId, params.premiumDebtRay.fromRayUp()).toInt256() - - params.premiumDebtRay.toInt256() - ); - // Mint tokens to liquidator and approve spoke deal(address(tokenList.weth), params.liquidator, spokeDrawnOwed + spokePremiumOwed); Utils.approve( @@ -155,48 +153,53 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser() public { - uint256 initialHub1UsdxBalance = tokenList.usdx.balanceOf(address(hub1)); - uint256 initialHub2Balance = tokenList.weth.balanceOf(address(hub2)); + uint256 initialCollateralReserveBalance = tokenList.usdx.balanceOf( + address(collateralReserveHub) + ); + uint256 initialDebtReserveBalance = tokenList.weth.balanceOf(address(debtReserveHub)); uint256 initialLiquidatorWethBalance = tokenList.weth.balanceOf(address(params.liquidator)); ISpoke.UserPosition memory debtPosition = liquidationLogicWrapper.getDebtPosition(params.user); - uint256 feeShares = hub1.previewRemoveByAssets(usdxAssetId, 6000e6) - - hub1.previewRemoveByAssets(usdxAssetId, 5900e6); + vm.expectCall( + address(collateralReserveHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4800e6)), + 1 + ); vm.expectCall( - address(hub1), - abi.encodeCall(IHubBase.previewRemoveByAssets, (usdxAssetId, 6000e6)), + address(collateralReserveHub), + abi.encodeCall(IHubBase.previewRemoveByShares, (usdxAssetId, 4720e6)), 1 ); vm.expectCall( - address(hub1), + address(collateralReserveHub), abi.encodeCall(IHubBase.remove, (usdxAssetId, 5900e6, params.liquidator)), 1 ); vm.expectCall( - address(hub1), - abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, feeShares)), + address(collateralReserveHub), + abi.encodeCall(IHubBase.payFeeShares, (usdxAssetId, 80e6)), 1 ); vm.expectCall( - address(hub2), + address(debtReserveHub), abi.encodeCall( IHubBase.restore, ( wethAssetId, - 2e18, + 2.1e18, _getExpectedPremiumDelta({ - hub: hub2, + hub: IHub(address(debtReserveHub)), assetId: wethAssetId, oldPremiumShares: debtPosition.premiumShares, oldPremiumOffsetRay: debtPosition.premiumOffsetRay, drawnShares: 0, riskPremium: 0, - restoredPremiumRay: 0.5e18 * WadRayMath.RAY + restoredPremiumRay: 0.4e18 * WadRayMath.RAY }) ) ), @@ -206,11 +209,18 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { bool hasDeficit = liquidationLogicWrapper.liquidateUser(params); assertEq(hasDeficit, false); - assertEq(tokenList.usdx.balanceOf(address(hub1)), initialHub1UsdxBalance - 5900e6); + assertEq( + tokenList.usdx.balanceOf(address(collateralReserveHub)), + initialCollateralReserveBalance - 5900e6 + ); assertEq(tokenList.usdx.balanceOf(address(params.liquidator)), 5900e6); - assertApproxEqAbs(hub1.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), 100e6, 1); + assertApproxEqAbs( + collateralReserveHub.getSpokeAddedShares(usdxAssetId, address(treasurySpoke)), + 80e6, + 1 + ); - assertEq(tokenList.weth.balanceOf(address(hub2)), initialHub2Balance + 2.5e18); + assertEq(tokenList.weth.balanceOf(address(debtReserveHub)), initialDebtReserveBalance + 2.5e18); assertEq( tokenList.weth.balanceOf(address(params.liquidator)), initialLiquidatorWethBalance - 2.5e18 @@ -224,7 +234,7 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser_revertsWith_MustNotLeaveDust_Debt() public { - params.totalDebtValue *= 2; + params.totalDebtValueRay *= 2; params.debtToCover = 4.9e18; liquidationLogicWrapper.setCollateralPositionSuppliedShares( liquidationLogicWrapper.getCollateralPosition(params.user).suppliedShares * 2 @@ -234,17 +244,9 @@ contract LiquidationLogicLiquidateUserTest is LiquidationLogicBaseTest { } function test_liquidateUser_revertsWith_MustNotLeaveDust_Collateral() public { - liquidationLogicWrapper.setCollateralPositionSuppliedShares(6500e6); + liquidationLogicWrapper.setCollateralPositionSuppliedShares(5200e6); params.debtToCover = 2.6e18; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.liquidateUser(params); } - - function updateStorage(ISpoke.LiquidationConfig memory config) internal { - liquidationLogicWrapper.setLiquidationConfig(config); - } - - function updateStorage(ISpoke.DynamicReserveConfig memory config) internal { - liquidationLogicWrapper.setDynamicCollateralConfig(config); - } } diff --git a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol index 31068db8f..b8817f45b 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.LiquidationAmounts.t.sol @@ -7,6 +7,8 @@ import 'tests/unit/libraries/LiquidationLogic/LiquidationLogic.Base.t.sol'; contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { using MathUtils for uint256; using PercentageMath for uint256; + using WadRayMath for uint256; + using LiquidationLogic for uint256; function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_NoCollateralDust( LiquidationLogic.CalculateLiquidationAmountsParams memory params @@ -15,49 +17,38 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, - params.collateralAssetPrice, - params.collateralAssetUnit - ) + - 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, - params.collateralAssetPrice, - params.collateralAssetUnit - ) + + uint256 dustSharesBufferLowerBound = params.collateralReserveHub.previewRemoveByAssets( + params.collateralReserveAssetId, + _convertValueToAmount( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + params.collateralAssetPrice, + 10 ** params.collateralAssetDecimals + ) + 1 + ); + + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate + dustSharesBufferLowerBound + 1, + expectedLiquidationAmounts.collateralSharesToLiquidate + + dustSharesBufferLowerBound + + 1 + MAX_SUPPLY_AMOUNT ); params.debtToCover = bound( params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, - MAX_SUPPLY_AMOUNT + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_NoDebtLeft( @@ -68,79 +59,42 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate + MAX_SUPPLY_AMOUNT + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate, + expectedLiquidationAmounts.collateralSharesToLiquidate + MAX_SUPPLY_AMOUNT ); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); + params.debtToCover = bound( + params.debtToCover, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX + ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq(liquidationAmounts.debtToLiquidate, params.debtReserveBalance, 'debtToLiquidate'); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_EnoughCollateral_CollateralDust( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { - params = _bound(params); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); + params = _boundWithCollateralDustAdjustment(params); LiquidationLogic.LiquidationAmounts memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, - params.collateralAssetPrice, - params.collateralAssetUnit - ) - ); - - if (expectedLiquidationAmounts.debtToLiquidate < params.debtReserveBalance) { + if (expectedLiquidationAmounts.drawnSharesToLiquidate < params.drawnShares) { expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); } - params.debtToCover = bound( - params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, - MAX_SUPPLY_AMOUNT - ); - LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_InsufficientCollateral( @@ -149,11 +103,11 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { params = _bound(params); LiquidationLogic.LiquidationAmounts memory rawLiquidationAmounts = _calculateRawLiquidationAmounts(params); - vm.assume(rawLiquidationAmounts.collateralToLiquidate > 0); - params.collateralReserveBalance = bound( - params.collateralReserveBalance, + vm.assume(rawLiquidationAmounts.collateralSharesToLiquidate > 0); + params.suppliedShares = bound( + params.suppliedShares, 0, - rawLiquidationAmounts.collateralToLiquidate - 1 + rawLiquidationAmounts.collateralSharesToLiquidate - 1 ); LiquidationLogic.LiquidationAmounts @@ -161,41 +115,35 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { params.debtToCover = bound( params.debtToCover, - expectedLiquidationAmounts.debtToLiquidate, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), MAX_SUPPLY_AMOUNT ); LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts(params); - assertEq( - liquidationAmounts.collateralToLiquidate, - expectedLiquidationAmounts.collateralToLiquidate, - 'collateralToLiquidate' - ); - assertApproxEqAbs( - liquidationAmounts.collateralToLiquidator, - expectedLiquidationAmounts.collateralToLiquidator, - 1, - 'collateralToLiquidator' - ); - assertEq( - liquidationAmounts.debtToLiquidate, - expectedLiquidationAmounts.debtToLiquidate, - 'debtToLiquidate' - ); + assertApproxEqAbs(liquidationAmounts, expectedLiquidationAmounts); } function test_calculateLiquidationAmounts_fuzz_revertsWith_MustNotLeaveDust_Debt( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { params = _boundWithDebtDustAdjustment(params); - if (params.debtToCover >= params.debtReserveBalance) { - params.debtToCover = params.debtReserveBalance - 1; + uint256 debtAssetsToRestore = _calculateDebtAssetsToRestore( + params.drawnShares, + params.premiumDebtRay, + params.drawnIndex + ); + if (params.debtToCover >= debtAssetsToRestore) { + params.debtToCover = debtAssetsToRestore - 1; } LiquidationLogic.LiquidationAmounts memory rawLiquidationAmounts = _calculateRawLiquidationAmounts(params); - params.collateralReserveBalance = rawLiquidationAmounts.collateralToLiquidate; + params.suppliedShares = rawLiquidationAmounts.collateralSharesToLiquidate; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.calculateLiquidationAmounts(params); @@ -204,52 +152,50 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { function test_calculateLiquidationAmounts_fuzz_revertsWith_MustNotLeaveDust_Collateral( LiquidationLogic.CalculateLiquidationAmountsParams memory params ) public { - params = _bound(params); - params.debtToCover = bound(params.debtToCover, params.debtReserveBalance, UINT256_MAX); - + params = _boundWithCollateralDustAdjustment(params); LiquidationLogic.LiquidationAmounts - memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); - - params.collateralReserveBalance = bound( - params.collateralReserveBalance, - expectedLiquidationAmounts.collateralToLiquidate + 1, - expectedLiquidationAmounts.collateralToLiquidate + - _convertValueToAmount( - LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, - params.collateralAssetPrice, - params.collateralAssetUnit - ) - ); - - if (expectedLiquidationAmounts.debtToLiquidate < params.debtReserveBalance) { - expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); - } - - vm.assume(expectedLiquidationAmounts.debtToLiquidate > 0); - params.debtToCover = expectedLiquidationAmounts.debtToLiquidate - 1; + memory expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); + vm.assume(expectedLiquidationAmounts.premiumDebtRayToLiquidate > 0); + params.debtToCover = + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ) - 1; vm.expectRevert(ISpoke.MustNotLeaveDust.selector); liquidationLogicWrapper.calculateLiquidationAmounts(params); } - function test_calculateLiquidationAmounts_EnoughCollateral() public view { + function test_calculateLiquidationAmounts_EnoughCollateral() public { // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 3 * 1.6 + 0.5, 3) = 2.5 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (2.5 - 0.5) / 1.6 = 1.25 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // bonus collateral = 6000 - 6000 / 120% = 1000 - // collateral fee = 1000 * 10% = 100 - // collateral to liquidator = 6000 - 100 = 5900 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // bonus collateral shares = 4800 - 4800 / 120% = 800 + // collateral fee shares = 800 * 10% = 80 + // collateral shares to liquidator = 4800 - 80 = 4720 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice(collateralReserveHub, collateralAssetId, 12_500.25e6, 10_000e6); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: 11_000e6, - collateralAssetUnit: 10 ** 6, + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 10_000e6, + collateralAssetDecimals: 6, collateralAssetPrice: 1e8, - debtReserveBalance: 5e18, - totalDebtValue: 10_000e26, - debtAssetUnit: 10 ** 18, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, debtAssetPrice: 2000e8, debtToCover: 3e18, collateralFactor: 50_00, @@ -262,31 +208,48 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { }) ); - assertEq(liquidationAmounts.collateralToLiquidate, 6000e6, 'collateralToLiquidate'); - assertEq(liquidationAmounts.collateralToLiquidator, 5900e6, 'collateralToLiquidator'); - assertEq(liquidationAmounts.debtToLiquidate, 2.5e18, 'debtToLiquidate'); + assertApproxEqAbs( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 4800e6, + collateralSharesToLiquidator: 4720e6, + drawnSharesToLiquidate: 1.25e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); } - function test_calculateLiquidationAmounts_InsufficientCollateral() public view { + function test_calculateLiquidationAmounts_InsufficientCollateral() public { // variable liquidation bonus is max: 120% // liquidation penalty: 1.2 * 0.5 = 0.6 // debtToTarget = $10000 * (1 - 0.8) / (1 - 0.6) / $2000 = 2.5 - // max debt to liquidate = min(2.5, 5, 3) = 2.5 + // max debt to liquidate = min(2.5, 3 * 1.6 + 0.5, 3) = 2.5 // collateral to liquidate = 2.5 * 120% * $2000 / $1 = 6000 - // total reserve collateral = 3000 - // adjusted debt to liquidate = 3000 / 120% * $1 / $2000 = 1.25 - // bonus collateral = 3000 - 3000 / 120% = 500 - // collateral fee = 500 * 10% = 50 - // collateral to liquidator = 3000 - 50 = 2950 + // collateral shares to liquidate = 6000 / 1.25 = 4800 + // supplied shares: 4500 + // adjusted debt to liquidate = 4500 * 1.25 / 120% * $1 / $2000 = 2.34375 + // premiumDebtRayToLiquidate = 0.5 + // drawnSharesToLiquidate = (2.34375 - 0.5) / 1.6 = 1.15234375 + // bonus collateral shares = 4500 - 4500 / 120% = 750 + // collateral fee shares = 750 * 10% = 75 + // collateral shares to liquidator = 4500 - 75 = 4425 + IHub collateralReserveHub = hub1; + uint256 collateralAssetId = vm.randomUint(0, collateralReserveHub.getAssetCount() - 1); + _mockSupplySharePrice(collateralReserveHub, collateralAssetId, 12500.25e6, 10_000e6); + LiquidationLogic.LiquidationAmounts memory liquidationAmounts = liquidationLogicWrapper .calculateLiquidationAmounts( LiquidationLogic.CalculateLiquidationAmountsParams({ - collateralReserveBalance: 3000e6, - collateralAssetUnit: 10 ** 6, + collateralReserveHub: collateralReserveHub, + collateralReserveAssetId: collateralAssetId, + suppliedShares: 4500e6, + collateralAssetDecimals: 6, collateralAssetPrice: 1e8, - debtReserveBalance: 5e18, - totalDebtValue: 10_000e26, - debtAssetUnit: 10 ** 18, + drawnShares: 3e18, + premiumDebtRay: 0.5e18 * 1e27, + drawnIndex: 1.6e27, + totalDebtValueRay: 10_000e26 * WadRayMath.RAY, + debtAssetDecimals: 18, debtAssetPrice: 2000e8, debtToCover: 3e18, collateralFactor: 50_00, @@ -299,9 +262,15 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { }) ); - assertEq(liquidationAmounts.collateralToLiquidate, 3000e6, 'collateralToLiquidate'); - assertEq(liquidationAmounts.collateralToLiquidator, 2950e6, 'collateralToLiquidator'); - assertEq(liquidationAmounts.debtToLiquidate, 1.25e18, 'debtToLiquidate'); + assertApproxEqAbs( + liquidationAmounts, + LiquidationLogic.LiquidationAmounts({ + collateralSharesToLiquidate: 4500e6, + collateralSharesToLiquidator: 4425e6, + drawnSharesToLiquidate: 1.15234375e18, + premiumDebtRayToLiquidate: 0.5e18 * 1e27 + }) + ); } function _calculateRawLiquidationAmounts( @@ -314,24 +283,35 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { maxLiquidationBonus: params.maxLiquidationBonus }); - uint256 debtToLiquidate = liquidationLogicWrapper.calculateDebtToLiquidate( - _getCalculateDebtToLiquidateParams(params) + (uint256 drawnSharesToLiquidate, uint256 premiumDebtRayToLiquidate) = liquidationLogicWrapper + .calculateDebtToLiquidate(_getCalculateDebtToLiquidateParams(params)); + uint256 debtRayToLiquidate = drawnSharesToLiquidate * params.drawnIndex + + premiumDebtRayToLiquidate; + uint256 collateralToLiquidate = Math.mulDiv( + debtRayToLiquidate, + params.debtAssetPrice * (10 ** params.collateralAssetDecimals) * liquidationBonus, + (10 ** params.debtAssetDecimals) * + params.collateralAssetPrice * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + Math.Rounding.Floor ); - uint256 collateralToLiquidate = debtToLiquidate.mulDivDown( - params.debtAssetPrice * params.collateralAssetUnit * liquidationBonus, - params.debtAssetUnit * params.collateralAssetPrice * PercentageMath.PERCENTAGE_FACTOR + uint256 collateralSharesToLiquidate = params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + collateralToLiquidate ); - uint256 collateralToLiquidator = _calculateCollateralToLiquidator( - collateralToLiquidate, + uint256 collateralSharesToLiquidator = _calculateCollateralSharesToLiquidator( + collateralSharesToLiquidate, liquidationBonus, params.liquidationFee ); return LiquidationLogic.LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } @@ -345,34 +325,130 @@ contract LiquidationLogicLiquidationAmountsTest is LiquidationLogicBaseTest { maxLiquidationBonus: params.maxLiquidationBonus }); - uint256 collateralToLiquidate = params.collateralReserveBalance; - uint256 collateralToLiquidator = _calculateCollateralToLiquidator( - collateralToLiquidate, + uint256 collateralSharesToLiquidate = params.suppliedShares; + uint256 collateralSharesToLiquidator = _calculateCollateralSharesToLiquidator( + collateralSharesToLiquidate, liquidationBonus, params.liquidationFee ); - uint256 debtToLiquidate = collateralToLiquidate - .mulDivUp( - params.collateralAssetPrice * params.debtAssetUnit * PercentageMath.PERCENTAGE_FACTOR, - params.collateralAssetUnit * params.debtAssetPrice * liquidationBonus - ) - .min(params.debtReserveBalance); + + uint256 debtRayToLiquidate = Math.mulDiv( + params.collateralReserveHub.previewAddByShares( + params.collateralReserveAssetId, + collateralSharesToLiquidate + ), + params.collateralAssetPrice * + (10 ** params.debtAssetDecimals) * + PercentageMath.PERCENTAGE_FACTOR * + WadRayMath.RAY, + (10 ** params.collateralAssetDecimals) * params.debtAssetPrice * liquidationBonus, + Math.Rounding.Ceil + ); + + uint256 premiumDebtRayToLiquidate = debtRayToLiquidate.fromRayUp().toRay().min( + params.premiumDebtRay + ); + uint256 drawnSharesToLiquidate; + if (premiumDebtRayToLiquidate < debtRayToLiquidate) { + drawnSharesToLiquidate = (debtRayToLiquidate - premiumDebtRayToLiquidate).divUp( + params.drawnIndex + ); + } + + if (drawnSharesToLiquidate > params.drawnShares) { + drawnSharesToLiquidate = params.drawnShares; + } return LiquidationLogic.LiquidationAmounts({ - collateralToLiquidate: collateralToLiquidate, - collateralToLiquidator: collateralToLiquidator, - debtToLiquidate: debtToLiquidate + collateralSharesToLiquidate: collateralSharesToLiquidate, + collateralSharesToLiquidator: collateralSharesToLiquidator, + drawnSharesToLiquidate: drawnSharesToLiquidate, + premiumDebtRayToLiquidate: premiumDebtRayToLiquidate }); } - function _calculateCollateralToLiquidator( - uint256 collateralToLiquidate, + function _boundWithCollateralDustAdjustment( + LiquidationLogic.CalculateLiquidationAmountsParams memory params + ) internal virtual returns (LiquidationLogic.CalculateLiquidationAmountsParams memory) { + params = _bound(params); + params.drawnShares = MAX_SUPPLY_AMOUNT; + params.debtToCover = UINT256_MAX; + + // bound price such that 1 supply share is worth less than DUST_LIQUIDATION_THRESHOLD + params.collateralAssetPrice = bound( + params.collateralAssetPrice, + 1, + params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + _convertDecimals( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD, + 18, + params.collateralAssetDecimals, + false + ) + ) + ); + + LiquidationLogic.LiquidationAmounts + memory expectedLiquidationAmounts = _calculateRawLiquidationAmounts(params); + + uint256 dustSharesBufferUpperBound = params.collateralReserveHub.previewAddByAssets( + params.collateralReserveAssetId, + _convertValueToAmount( + LiquidationLogic.DUST_LIQUIDATION_THRESHOLD - 1, + params.collateralAssetPrice, + 10 ** params.collateralAssetDecimals + ) + ); + + params.suppliedShares = bound( + params.suppliedShares, + expectedLiquidationAmounts.collateralSharesToLiquidate + 1, + expectedLiquidationAmounts.collateralSharesToLiquidate + _max(1, dustSharesBufferUpperBound) + ); + + expectedLiquidationAmounts = _calculateAdjustedLiquidationAmounts(params); + + params.debtToCover = bound( + params.debtToCover, + _calculateDebtAssetsToRestore( + expectedLiquidationAmounts.drawnSharesToLiquidate, + expectedLiquidationAmounts.premiumDebtRayToLiquidate, + params.drawnIndex + ), + UINT256_MAX + ); + + return params; + } + + function _calculateCollateralSharesToLiquidator( + uint256 collateralSharesToLiquidate, uint256 liquidationBonus, uint256 liquidationFee ) internal pure returns (uint256) { - uint256 bonusCollateral = collateralToLiquidate - - collateralToLiquidate.percentDivUp(liquidationBonus); - return collateralToLiquidate - bonusCollateral.percentMulDown(liquidationFee); + uint256 bonusCollateralShares = collateralSharesToLiquidate - + collateralSharesToLiquidate.percentDivUp(liquidationBonus); + return collateralSharesToLiquidate - bonusCollateralShares.percentMulDown(liquidationFee); + } + + function assertApproxEqAbs( + 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/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol index c94fe824d..3a35285bc 100644 --- a/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol +++ b/tests/unit/libraries/LiquidationLogic/LiquidationLogic.ValidateLiquidationCall.t.sol @@ -19,8 +19,8 @@ contract LiquidationLogicValidateLiquidationCallTest is LiquidationLogicBaseTest liquidator: bob, collateralReserveFlags: collateralReserveFlags, debtReserveFlags: debtReserveFlags, - collateralReserveBalance: 120e6, - debtReserveBalance: 100e18, + suppliedShares: 120e6, + drawnShares: 100e18, debtToCover: 5e18, collateralFactor: 75_00, isUsingAsCollateral: true, @@ -191,13 +191,13 @@ contract LiquidationLogicValidateLiquidationCallTest is LiquidationLogicBaseTest } function test_validateLiquidationCall_revertsWith_ReserveNotSupplied() public { - params.collateralReserveBalance = 0; + params.suppliedShares = 0; vm.expectRevert(ISpoke.ReserveNotSupplied.selector); liquidationLogicWrapper.validateLiquidationCall(params); } function test_validateLiquidationCall_revertsWith_ReserveNotBorrowed() public { - params.debtReserveBalance = 0; + params.drawnShares = 0; vm.expectRevert(ISpoke.ReserveNotBorrowed.selector); liquidationLogicWrapper.validateLiquidationCall(params); }