diff --git a/script/upgrade/GracePeriodUpgradeTest.t.sol b/script/upgrade/GracePeriodUpgradeTest.t.sol new file mode 100644 index 0000000..c7a2bcb --- /dev/null +++ b/script/upgrade/GracePeriodUpgradeTest.t.sol @@ -0,0 +1,83 @@ +// test/upgrade/GracePeriodUpgradeTest.t.sol + +import { UpgradeGracePeriodLib } from "../../script/upgrade/UpgradeGracePeriod.s.sol"; +import { TroveManager } from "../../src/core/TroveManager.sol"; +import { ISatoshiPeriphery, LzSendParam } from "../../src/core/helpers/interfaces/ISatoshiPeriphery.sol"; +import { IBorrowerOperationsFacet } from "../../src/core/interfaces/IBorrowerOperationsFacet.sol"; +import { ILiquidationFacet } from "../../src/core/interfaces/ILiquidationFacet.sol"; +import { IPriceFeedAggregatorFacet } from "../../src/core/interfaces/IPriceFeedAggregatorFacet.sol"; +import { ITroveManager } from "../../src/core/interfaces/ITroveManager.sol"; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +interface IBeacon { + function upgradeTo(address newImplementation) external; + function implementation() external view returns (address); +} + +contract GracePeriodUpgradeTest is Test { + // Base network + address payable constant SATOSHI_X_APP_ADDRESS = payable(0x9a3c724ee9603A7550499bE73DC743B371811dd3); + address payable constant OWNER_ADDRESS = payable(0xd3e87B4B76E6F8bFf454AAFc2AD3271C5b317d47); + address payable constant TM_BEACON_ADDRESS = payable(0xefAa8B485355066fA0993A605466eEf0ec026860); + IERC20 constant WETH = IERC20(0x4200000000000000000000000000000000000006); + ISatoshiPeriphery constant SATOSHI_PERIPHERY = ISatoshiPeriphery(0x9d9f0D9a13d3bA201003DD2e8950059d2c08D782); + ITroveManager constant TROVE_MANAGER = ITroveManager(0xddac7d4e228c205197FE9961865FFE20173dE56B); + + function setUp() public { + vm.createSelectFork(vm.envString("RPC_URL_BASE"), 31_529_675); + } + + function beforeTestSetup(bytes4 testSelector) public returns (bytes[] memory beforeTestCalldata) { + if ( + testSelector == this.testFork_NormalLiquidationAfterUpgrade.selector + || testSelector == this.testFork_SetGracePeriod.selector + || testSelector == this.testFork_SyncGracePeriod.selector + ) { + beforeTestCalldata = new bytes[](1); + beforeTestCalldata[0] = abi.encodeWithSelector(this.testFork_UpgradeGracePeriod.selector); + } + } + + function testFork_UpgradeGracePeriod() public { + vm.startPrank(OWNER_ADDRESS); + + UpgradeGracePeriodLib.upgradeGracePeriod(SATOSHI_X_APP_ADDRESS); + IBeacon troveManagerBeacon = IBeacon(TM_BEACON_ADDRESS); + address newTroveManagerImpl = address(new TroveManager()); + troveManagerBeacon.upgradeTo(address(newTroveManagerImpl)); + } + + function testFork_NormalLiquidationAfterUpgrade() public { + address user = makeAddr("user"); + deal(address(WETH), user, 1000 ether); + + vm.startPrank(user); + + LzSendParam memory sendParam; + IBorrowerOperationsFacet(SATOSHI_X_APP_ADDRESS).setDelegateApproval(address(SATOSHI_PERIPHERY), true); + WETH.approve(address(SATOSHI_PERIPHERY), 1000 ether); + SATOSHI_PERIPHERY.openTrove(TROVE_MANAGER, 1e18, 1 ether, 2000e18, address(0), address(0), sendParam); + + // ICR < MCR + vm.mockCall( + address(SATOSHI_X_APP_ADDRESS), + abi.encodeWithSelector(IPriceFeedAggregatorFacet.fetchPrice.selector, address(WETH)), + abi.encode(2200e18) + ); + + ILiquidationFacet(SATOSHI_X_APP_ADDRESS).liquidate(TROVE_MANAGER, user); + } + + function testFork_SetGracePeriod() public { + vm.startPrank(OWNER_ADDRESS); + + ILiquidationFacet(SATOSHI_X_APP_ADDRESS).setGracePeriod(15 minutes); + } + + function testFork_SyncGracePeriod() public { + IBorrowerOperationsFacet(SATOSHI_X_APP_ADDRESS).syncGracePeriod(); + } +} diff --git a/script/upgrade/UpgradeGracePeriod.s.sol b/script/upgrade/UpgradeGracePeriod.s.sol new file mode 100644 index 0000000..d18cd66 --- /dev/null +++ b/script/upgrade/UpgradeGracePeriod.s.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import { Initializer } from "../../src/core/Initializer.sol"; +import { TroveManager } from "../../src/core/TroveManager.sol"; +import { BorrowerOperationsFacet } from "../../src/core/facets/BorrowerOperationsFacet.sol"; +import { LiquidationFacet } from "../../src/core/facets/LiquidationFacet.sol"; +import { IBorrowerOperationsFacet } from "../../src/core/interfaces/IBorrowerOperationsFacet.sol"; +import { IFactoryFacet } from "../../src/core/interfaces/IFactoryFacet.sol"; +import { ILiquidationFacet } from "../../src/core/interfaces/ILiquidationFacet.sol"; + +import { ISatoshiXApp } from "../../src/core/interfaces/ISatoshiXApp.sol"; +import { ITroveManager } from "../../src/core/interfaces/ITroveManager.sol"; +import { IERC2535DiamondCutInternal } from "@solidstate/contracts/interfaces/IERC2535DiamondCutInternal.sol"; +import { Script, console } from "forge-std/Script.sol"; + +address payable constant SATOSHI_X_APP_ADDRESS = payable(0x07BbC5A83B83a5C440D1CAedBF1081426d0AA4Ec); +address payable constant TM_BEACON_ADDRESS = payable(0x00); + +interface IBeacon { + function upgradeTo(address newImplementation) external; + function implementation() external view returns (address); +} + +library UpgradeGracePeriodLib { + function upgradeGracePeriod(address payable satoshiXApp) internal { + IERC2535DiamondCutInternal.FacetCut[] memory facetCuts = new IERC2535DiamondCutInternal.FacetCut[](6); + + // BorrowerOperationsFacet + address newBorrowerOperationsImpl = address(new BorrowerOperationsFacet()); + facetCuts[0] = IERC2535DiamondCutInternal.FacetCut({ + target: newBorrowerOperationsImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.REPLACE, + selectors: getBOSelectors() + }); + facetCuts[1] = IERC2535DiamondCutInternal.FacetCut({ + target: newBorrowerOperationsImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.ADD, + selectors: getBONewSelectors() + }); + + // LiquidationFacet + address newLiquidationFacetImpl = address(new LiquidationFacet()); + facetCuts[2] = IERC2535DiamondCutInternal.FacetCut({ + target: newLiquidationFacetImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.REPLACE, + selectors: getLiquidationSelectors() + }); + facetCuts[3] = IERC2535DiamondCutInternal.FacetCut({ + target: newLiquidationFacetImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.ADD, + selectors: getLiquidationNewSelectors() + }); + + // Initializer + address newInitializerImpl = address(new Initializer()); + facetCuts[4] = IERC2535DiamondCutInternal.FacetCut({ + target: newInitializerImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.REPLACE, + selectors: getInitializerSelectors() + }); + facetCuts[5] = IERC2535DiamondCutInternal.FacetCut({ + target: newInitializerImpl, + action: IERC2535DiamondCutInternal.FacetCutAction.ADD, + selectors: getInitializerNewSelectors() + }); + + // Upgrade and run initV2 + ISatoshiXApp XAPP = ISatoshiXApp(satoshiXApp); + bytes memory data = abi.encodeWithSelector(Initializer.initV2.selector); + XAPP.diamondCut(facetCuts, satoshiXApp, data); + } + + function getBOSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory selectors = new bytes4[](19); + selectors[0] = IBorrowerOperationsFacet.addColl.selector; + selectors[1] = IBorrowerOperationsFacet.adjustTrove.selector; + selectors[2] = IBorrowerOperationsFacet.checkRecoveryMode.selector; + selectors[3] = IBorrowerOperationsFacet.closeTrove.selector; + selectors[4] = IBorrowerOperationsFacet.fetchBalances.selector; + selectors[5] = IBorrowerOperationsFacet.getCompositeDebt.selector; + selectors[6] = IBorrowerOperationsFacet.getGlobalSystemBalances.selector; + selectors[7] = IBorrowerOperationsFacet.getTCR.selector; + selectors[8] = IBorrowerOperationsFacet.isApprovedDelegate.selector; + selectors[9] = IBorrowerOperationsFacet.minNetDebt.selector; + selectors[10] = IBorrowerOperationsFacet.openTrove.selector; + selectors[11] = IBorrowerOperationsFacet.removeTroveManager.selector; + selectors[12] = IBorrowerOperationsFacet.repayDebt.selector; + selectors[13] = IBorrowerOperationsFacet.setDelegateApproval.selector; + selectors[14] = IBorrowerOperationsFacet.setMinNetDebt.selector; + selectors[15] = IBorrowerOperationsFacet.troveManagersData.selector; + selectors[16] = IBorrowerOperationsFacet.withdrawColl.selector; + selectors[17] = IBorrowerOperationsFacet.withdrawDebt.selector; + selectors[18] = IBorrowerOperationsFacet.forceResetTM.selector; + return selectors; + } + + function getBONewSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory newSelectors = new bytes4[](1); + newSelectors[0] = IBorrowerOperationsFacet.syncGracePeriod.selector; + return newSelectors; + } + + function getLiquidationSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = ILiquidationFacet.batchLiquidateTroves.selector; + selectors[1] = ILiquidationFacet.liquidate.selector; + selectors[2] = ILiquidationFacet.liquidateTroves.selector; + return selectors; + } + + function getLiquidationNewSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory newSelectors = new bytes4[](1); + newSelectors[0] = ILiquidationFacet.setGracePeriod.selector; + return newSelectors; + } + + function getInitializerSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Initializer.init.selector; + return selectors; + } + + function getInitializerNewSelectors() internal pure returns (bytes4[] memory) { + bytes4[] memory newSelectors = new bytes4[](1); + newSelectors[0] = Initializer.initV2.selector; + return newSelectors; + } +} + +contract UpgradeGracePeriodScript is Script { + uint256 internal OWNER_PRIVATE_KEY; + + function setUp() public { + OWNER_PRIVATE_KEY = uint256(vm.envBytes32("OWNER_PRIVATE_KEY")); + } + + function run() public { + vm.startBroadcast(OWNER_PRIVATE_KEY); + + UpgradeGracePeriodLib.upgradeGracePeriod(SATOSHI_X_APP_ADDRESS); + IBeacon troveManagerBeacon = IBeacon(TM_BEACON_ADDRESS); + address newTroveManagerImpl = address(new TroveManager()); + troveManagerBeacon.upgradeTo(address(newTroveManagerImpl)); + + vm.stopBroadcast(); + } +} diff --git a/src/core/AppStorage.sol b/src/core/AppStorage.sol index adece3f..e94cb5f 100644 --- a/src/core/AppStorage.sol +++ b/src/core/AppStorage.sol @@ -102,6 +102,9 @@ library AppStorage { mapping(address => AssetConfig) assetConfigs; mapping(address => bool) isAssetSupported; mapping(address => uint256) dailyMintCount; + // Recovery mode liquidation grace period + uint128 lastGracePeriodStartTimestamp; + uint128 recoveryModeGracePeriodDuration; } function layout() internal pure returns (Layout storage s) { diff --git a/src/core/Config.sol b/src/core/Config.sol index 9477441..83e8fae 100644 --- a/src/core/Config.sol +++ b/src/core/Config.sol @@ -29,6 +29,8 @@ library Config { /* Liquidation */ uint256 internal constant _100_PCT = 1_000_000_000_000_000_000; // 1e18 == 100% + uint256 internal constant _110_PCT = 1_100_000_000_000_000_000; // 110% + /* PriceFeedAggregator */ uint256 public constant PRICE_TARGET_DIGITS = 18; @@ -45,4 +47,8 @@ library Config { * Farming */ uint256 constant FARMING_PRECISION = 1e4; + + /* Recovery mode grace period */ + uint128 constant UNSET_TIMESTAMP = type(uint128).max; + uint128 constant MINIMUM_GRACE_PERIOD = 15 minutes; } diff --git a/src/core/Initializer.sol b/src/core/Initializer.sol index 47dc462..f078988 100644 --- a/src/core/Initializer.sol +++ b/src/core/Initializer.sol @@ -108,5 +108,20 @@ contract Initializer is Initializable, AccessControlInternal, OwnableInternal { */ // debtToken // rewardManager + + /** + * LiquidationFacet + */ + initV2(); + } + + function initV2() public reinitializer(2) { + AppStorage.Layout storage s = AppStorage.layout(); + + /** + * LiquidationFacet + */ + s.lastGracePeriodStartTimestamp = Config.UNSET_TIMESTAMP; + s.recoveryModeGracePeriodDuration = Config.MINIMUM_GRACE_PERIOD; } } diff --git a/src/core/TroveManager.sol b/src/core/TroveManager.sol index 8441f50..969d203 100644 --- a/src/core/TroveManager.sol +++ b/src/core/TroveManager.sol @@ -678,6 +678,8 @@ contract TroveManager is ITroveManager, Initializable, OwnableUpgradeable { _sendCollateral(msg.sender, totals.collateralToSendToRedeemer); _resetState(); + IBorrowerOperationsFacet(satoshiXApp).syncGracePeriod(); + if (interestPayable > 0) { collectInterests(); } @@ -1109,6 +1111,7 @@ contract TroveManager is ITroveManager, Initializable, OwnableUpgradeable { external { _requireCallerIsSatoshiXapp(); + // redistribute debt and collateral _redistributeDebtAndColl(_debt, _coll); @@ -1134,6 +1137,8 @@ contract TroveManager is ITroveManager, Initializable, OwnableUpgradeable { collateralToken.safeIncreaseAllowance(rewardManager, collGasCompToFeeReceiver); } IRewardManager(rewardManager).increaseCollPerUintStaked(collGasCompToFeeReceiver); + + IBorrowerOperationsFacet(satoshiXApp).syncGracePeriod(); } function _redistributeDebtAndColl(uint256 _debt, uint256 _coll) internal { diff --git a/src/core/facets/BorrowerOperationsFacet.sol b/src/core/facets/BorrowerOperationsFacet.sol index 2a70060..73fe0d2 100644 --- a/src/core/facets/BorrowerOperationsFacet.sol +++ b/src/core/facets/BorrowerOperationsFacet.sol @@ -16,6 +16,7 @@ import { IDebtToken } from "../interfaces/IDebtToken.sol"; import { ITroveManager } from "../interfaces/ITroveManager.sol"; import { BorrowerOperationsLib } from "../libs/BorrowerOperationsLib.sol"; +import { RecoveryModeGracePeriodLib } from "../libs/RecoveryModeGracePeriodLib.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; @@ -26,6 +27,7 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter using SafeERC20 for IERC20; using SafeERC20 for IDebtToken; using BorrowerOperationsLib for *; + using RecoveryModeGracePeriodLib for AppStorage.Layout; struct LocalVariables_adjustTrove { uint256 price; @@ -177,20 +179,28 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter vars.ICR = SatoshiMath._computeCR(scaledCollateralAmount, vars.compositeDebt, vars.price); vars.NICR = SatoshiMath._computeNominalCR(_collateralAmount, vars.compositeDebt); + uint256 newTCR = BorrowerOperationsLib._getNewTCRFromTroveChange( + vars.totalPricedCollateral, + vars.totalDebt, + _collateralAmount * vars.price, + true, + vars.compositeDebt, + true, + decimals + ); // bools: coll increase, debt increase + if (isRecoveryMode) { BorrowerOperationsLib._requireICRisAboveCCR(vars.ICR); + if (newTCR < Config.CCR) { + s._startGracePeriod(); + } else { + // Deposit coll could exit Recovery Mode + s._endGracePeriod(); + } } else { BorrowerOperationsLib._requireICRisAboveMCR(vars.ICR, troveManager.MCR()); - uint256 newTCR = BorrowerOperationsLib._getNewTCRFromTroveChange( - vars.totalPricedCollateral, - vars.totalDebt, - _collateralAmount * vars.price, - true, - vars.compositeDebt, - true, - decimals - ); // bools: coll increase, debt increase BorrowerOperationsLib._requireNewTCRisAboveCCR(newTCR); + s._endGracePeriod(); } // Create the trove @@ -427,6 +437,8 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter // Burn the repaid Debt from the user's balance and the gas compensation from the Gas Pool s.debtToken.burnWithGasCompensation(msg.sender, debt - s.gasCompensation); + syncGracePeriod(); + // collect interest payable to rewardManager if (troveManager.interestPayable() != 0) { troveManager.collectInterests(); @@ -479,7 +491,6 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter uint8 decimals ) internal - pure { /* *In Recovery Mode, only allow: @@ -494,6 +505,7 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter * - The new ICR is above MCR * - The adjustment won't pull the TCR below CCR */ + AppStorage.Layout storage s = AppStorage.layout(); // Get the trove's old ICR before the adjustment uint256 scaledCollAmount = SatoshiMath._getScaledCollateralAmount(_vars.coll, decimals); @@ -511,25 +523,35 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter decimals ); + uint256 newTCR = BorrowerOperationsLib._getNewTCRFromTroveChange( + totalPricedCollateral, + totalDebt, + _vars.collChange * _vars.price, + _vars.isCollIncrease, + _vars.netDebtChange, + _isDebtIncrease, + decimals + ); + if (_isRecoveryMode) { require(_collWithdrawal == 0, "BorrowerOps: Collateral withdrawal not permitted Recovery Mode"); if (_isDebtIncrease) { BorrowerOperationsLib._requireICRisAboveCCR(newICR); BorrowerOperationsLib._requireNewICRisAboveOldICR(newICR, oldICR); } + + if (newTCR < Config.CCR) { + s._startGracePeriod(); + } else { + // Deposit coll could exit Recovery Mode + s._endGracePeriod(); + } } else { // if Normal Mode BorrowerOperationsLib._requireICRisAboveMCR(newICR, _vars.MCR); - uint256 newTCR = BorrowerOperationsLib._getNewTCRFromTroveChange( - totalPricedCollateral, - totalDebt, - _vars.collChange * _vars.price, - _vars.isCollIncrease, - _vars.netDebtChange, - _isDebtIncrease, - decimals - ); BorrowerOperationsLib._requireNewTCRisAboveCCR(newTCR); + + s._endGracePeriod(); } } @@ -538,4 +560,9 @@ contract BorrowerOperationsFacet is IBorrowerOperationsFacet, AccessControlInter Balances memory balances = s._fetchBalances(); (, totalPricedCollateral, totalDebt) = BorrowerOperationsLib._getTCRData(balances); } + + function syncGracePeriod() public { + AppStorage.Layout storage s = AppStorage.layout(); + s._syncGracePeriod(s._inRecoveryMode()); + } } diff --git a/src/core/facets/LiquidationFacet.sol b/src/core/facets/LiquidationFacet.sol index a0c0cf2..3ff8d52 100644 --- a/src/core/facets/LiquidationFacet.sol +++ b/src/core/facets/LiquidationFacet.sol @@ -16,6 +16,7 @@ import { IStabilityPoolFacet } from "../interfaces/IStabilityPoolFacet.sol"; import { ITroveManager } from "../interfaces/ITroveManager.sol"; import { BorrowerOperationsLib } from "../libs/BorrowerOperationsLib.sol"; +import { RecoveryModeGracePeriodLib } from "../libs/RecoveryModeGracePeriodLib.sol"; import { StabilityPoolLib } from "../libs/StabilityPoolLib.sol"; import { AccessControlInternal } from "@solidstate/contracts/access/access_control/AccessControlInternal.sol"; import { OwnableInternal } from "@solidstate/contracts/access/ownable/OwnableInternal.sol"; @@ -25,6 +26,7 @@ import { IERC20Metadata } from "@solidstate/contracts/token/ERC20/metadata/IERC2 contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableInternal { using StabilityPoolLib for AppStorage.Layout; using BorrowerOperationsLib for AppStorage.Layout; + using RecoveryModeGracePeriodLib for AppStorage.Layout; // --- Trove Liquidation functions --- @@ -78,11 +80,17 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn if (ICR <= Config._100_PCT && _inRecoveryMode()) { singleLiquidation = _liquidateWithoutSP(s, troveManager, account); _applyLiquidationValuesToTotals(totals, singleLiquidation); - } else if (ICR < troveManagerValues.MCR) { + } else if (ICR < Config._110_PCT) { singleLiquidation = _liquidateNormalMode(s, troveManager, account, debtInStabPool, troveManagerValues.sunsetting); debtInStabPool -= singleLiquidation.debtToOffset; _applyLiquidationValuesToTotals(totals, singleLiquidation); + } else if (ICR < troveManagerValues.MCR) { + singleLiquidation = _tryLiquidateWithCap( + troveManager, account, debtInStabPool, Config._110_PCT, troveManagerValues.price + ); + debtInStabPool -= singleLiquidation.debtToOffset; + _applyLiquidationValuesToTotals(totals, singleLiquidation); } else { break; } // break if the loop reaches a Trove with ICR >= MCR @@ -108,9 +116,10 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn uint256 TCR = SatoshiMath._computeCR(entireSystemColl, entireSystemDebt); if (TCR >= Config.CCR || ICR >= TCR) break; + if (_checkRecoveryModeGracePeriod()) break; singleLiquidation = _tryLiquidateWithCap( - _troveManager, account, debtInStabPool, troveManagerValues.MCR, troveManagerValues.price + _troveManager, account, debtInStabPool, Config._110_PCT, troveManagerValues.price ); if (singleLiquidation.debtToOffset == 0) continue; debtInStabPool -= singleLiquidation.debtToOffset; @@ -132,6 +141,7 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn address(this), totals.totalDebtToOffset, totals.totalCollToSendToSP ); } + troveManager.finalizeLiquidation( msg.sender, totals.totalDebtToRedistribute, @@ -184,10 +194,15 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn uint256 ICR = troveManager.getCurrentICR(account, troveManagerValues.price); if (ICR <= Config._100_PCT && _inRecoveryMode()) { singleLiquidation = _liquidateWithoutSP(s, troveManager, account); - } else if (ICR < troveManagerValues.MCR) { + } else if (ICR < Config._110_PCT) { singleLiquidation = _liquidateNormalMode(s, troveManager, account, debtInStabPool, troveManagerValues.sunsetting); debtInStabPool -= singleLiquidation.debtToOffset; + } else if (ICR < troveManagerValues.MCR) { + singleLiquidation = _tryLiquidateWithCap( + troveManager, account, debtInStabPool, Config._110_PCT, troveManagerValues.price + ); + debtInStabPool -= singleLiquidation.debtToOffset; } else { // As soon as we find a trove with ICR >= MCR we need to start tracking the global TCR with the next loop break; @@ -212,15 +227,21 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn } if (ICR <= Config._100_PCT && _inRecoveryMode()) { singleLiquidation = _liquidateWithoutSP(s, troveManager, account); - } else if (ICR < troveManagerValues.MCR) { + } else if (ICR < Config._110_PCT) { singleLiquidation = _liquidateNormalMode(s, troveManager, account, debtInStabPool, troveManagerValues.sunsetting); + } else if (ICR < troveManagerValues.MCR) { + singleLiquidation = _tryLiquidateWithCap( + troveManager, account, debtInStabPool, Config._110_PCT, troveManagerValues.price + ); } else { if (troveManagerValues.sunsetting) continue; uint256 TCR = SatoshiMath._computeCR(entireSystemColl, entireSystemDebt); if (TCR >= Config.CCR || ICR >= TCR) continue; + // check the recovery mode grace period + if (_checkRecoveryModeGracePeriod()) break; singleLiquidation = _tryLiquidateWithCap( - troveManager, account, debtInStabPool, troveManagerValues.MCR, troveManagerValues.price + troveManager, account, debtInStabPool, Config._110_PCT, troveManagerValues.price ); if (singleLiquidation.debtToOffset == 0) continue; } @@ -457,4 +478,34 @@ contract LiquidationFacet is ILiquidationFacet, AccessControlInternal, OwnableIn uint256 TCR = SatoshiMath._computeCR(_entireSystemColl, _entireSystemDebt); return BorrowerOperationsLib._checkRecoveryMode(TCR); } + + function _checkRecoveryModeGracePeriod() internal view returns (bool) { + AppStorage.Layout storage s = AppStorage.layout(); + uint128 cachedLastGracePeriodStartTimestamp = s.lastGracePeriodStartTimestamp; + if (cachedLastGracePeriodStartTimestamp == Config.UNSET_TIMESTAMP) { + // grace period has never been triggered + return true; + } + if (uint128(block.timestamp) < cachedLastGracePeriodStartTimestamp + s.recoveryModeGracePeriodDuration) { + // it is still in grace period + return true; + } + + return false; + } + + // --- Grace Period --- + function setGracePeriod(uint128 _gracePeriod) external onlyRole(Config.OWNER_ROLE) { + AppStorage.Layout storage s = AppStorage.layout(); + if (_gracePeriod < Config.MINIMUM_GRACE_PERIOD) revert GracePeriodTooShort(_gracePeriod); + + s._syncGracePeriod(_inRecoveryMode()); + s.recoveryModeGracePeriodDuration = _gracePeriod; + emit GracePeriodDurationSet(_gracePeriod); + } + + function syncGracePeriod() public { + AppStorage.Layout storage s = AppStorage.layout(); + s._syncGracePeriod(_inRecoveryMode()); + } } diff --git a/src/core/interfaces/IBorrowerOperationsFacet.sol b/src/core/interfaces/IBorrowerOperationsFacet.sol index e9fe9ab..dacf832 100644 --- a/src/core/interfaces/IBorrowerOperationsFacet.sol +++ b/src/core/interfaces/IBorrowerOperationsFacet.sol @@ -118,4 +118,6 @@ interface IBorrowerOperationsFacet { returns (IERC20 collateralToken, uint16 index); function forceResetTM(ITroveManager[] calldata _troveManagers) external; + + function syncGracePeriod() external; } diff --git a/src/core/interfaces/ILiquidationFacet.sol b/src/core/interfaces/ILiquidationFacet.sol index 6207d20..6b32329 100644 --- a/src/core/interfaces/ILiquidationFacet.sol +++ b/src/core/interfaces/ILiquidationFacet.sol @@ -91,6 +91,12 @@ interface ILiquidationFacet { /// @param _operation The operation type event TroveLiquidated(address indexed _borrower, uint256 _debt, uint256 _coll, uint8 _operation); + event GracePeriodDurationSet(uint128 _gracePeriod); + + error GracePeriodTooShort(uint128 gracePeriod); + error NotInGracePeriod(); + error InGracePeriod(); + /// @notice Batch liquidates a list of troves /// @param troveManager The Trove Manager handling the liquidation /// @param _troveArray The array of trove addresses to be liquidated @@ -106,4 +112,11 @@ interface ILiquidationFacet { /// @param maxTrovesToLiquidate The maximum number of troves to liquidate /// @param maxICR The maximum individual collateral ratio function liquidateTroves(ITroveManager troveManager, uint256 maxTrovesToLiquidate, uint256 maxICR) external; + + /// @notice Set the grace period for recovery mode + /// @param _gracePeriod The new grace period + function setGracePeriod(uint128 _gracePeriod) external; + + /// @notice Sync the grace period + function syncGracePeriod() external; } diff --git a/src/core/libs/BorrowerOperationsLib.sol b/src/core/libs/BorrowerOperationsLib.sol index b9bd3c6..6d9fca8 100644 --- a/src/core/libs/BorrowerOperationsLib.sol +++ b/src/core/libs/BorrowerOperationsLib.sol @@ -209,4 +209,10 @@ library BorrowerOperationsLib { _maxFeePercentage <= SatoshiMath.DECIMAL_PRECISION, "Max fee percentage must less than or equal to 100%" ); } + + function _inRecoveryMode(AppStorage.Layout storage s) internal returns (bool) { + (uint256 _entireSystemColl, uint256 _entireSystemDebt) = _getGlobalSystemBalances(s); + uint256 TCR = SatoshiMath._computeCR(_entireSystemColl, _entireSystemDebt); + return _checkRecoveryMode(TCR); + } } diff --git a/src/core/libs/RecoveryModeGracePeriodLib.sol b/src/core/libs/RecoveryModeGracePeriodLib.sol new file mode 100644 index 0000000..d0c7a55 --- /dev/null +++ b/src/core/libs/RecoveryModeGracePeriodLib.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { AppStorage } from "../AppStorage.sol"; +import { Config } from "../Config.sol"; + +library RecoveryModeGracePeriodLib { + event GracePeriodStart(); + event GracePeriodEnd(); + event GracePeriodDurationSet(uint128 _gracePeriod); + + function _startGracePeriod(AppStorage.Layout storage s) internal { + if (s.lastGracePeriodStartTimestamp == Config.UNSET_TIMESTAMP) { + s.lastGracePeriodStartTimestamp = uint128(block.timestamp); + + emit GracePeriodStart(); + } + } + + function _endGracePeriod(AppStorage.Layout storage s) internal { + if (s.lastGracePeriodStartTimestamp != Config.UNSET_TIMESTAMP) { + s.lastGracePeriodStartTimestamp = Config.UNSET_TIMESTAMP; + + emit GracePeriodEnd(); + } + } + + function _syncGracePeriod(AppStorage.Layout storage s, bool isRecoveryMode) internal { + if (isRecoveryMode) { + _startGracePeriod(s); + } else { + _endGracePeriod(s); + } + } +} diff --git a/test/Liquidate.t.sol b/test/Liquidate.t.sol index 7bb0d0e..bc3febc 100644 --- a/test/Liquidate.t.sol +++ b/test/Liquidate.t.sol @@ -247,6 +247,10 @@ contract LiquidateTest is DeployBase, TroveBase { uint256 collUser1Remaining = coll1 - vars.collToSendToSP; vm.startPrank(user4); + liquidationManagerProxy().syncGracePeriod(); + vm.expectRevert("TroveManager: nothing to liquidate"); + liquidationManagerProxy().liquidate(troveManagerBeaconProxy, user1); + vm.warp(block.timestamp + 20 minutes); // the user1 coll will capped at 1.1 * debt, no redistribution liquidationManagerProxy().liquidate(troveManagerBeaconProxy, user1); @@ -451,6 +455,8 @@ contract LiquidateTest is DeployBase, TroveBase { uint256 collUser1Remaining = coll1 - vars.collToSendToSP; vm.startPrank(user4); + liquidationManagerProxy().syncGracePeriod(); + vm.warp(block.timestamp + 20 minutes); // the user1 coll will capped at 1.1 * debt, no redistribution liquidationManagerProxy().liquidateTroves(troveManagerBeaconProxy, 10, CCR); diff --git a/test/utils/DeployBase.t.sol b/test/utils/DeployBase.t.sol index 360a45f..ddd8084 100644 --- a/test/utils/DeployBase.t.sol +++ b/test/utils/DeployBase.t.sol @@ -273,10 +273,12 @@ abstract contract DeployBase is Test { vm.startPrank(deployer); assert(address(liquidationFacet) == address(0)); // check if contract is not deployed liquidationFacet = ILiquidationFacet(address(new LiquidationFacet())); - bytes4[] memory selectors = new bytes4[](3); + bytes4[] memory selectors = new bytes4[](5); selectors[0] = ILiquidationFacet.batchLiquidateTroves.selector; selectors[1] = ILiquidationFacet.liquidate.selector; selectors[2] = ILiquidationFacet.liquidateTroves.selector; + selectors[3] = ILiquidationFacet.setGracePeriod.selector; + selectors[4] = ILiquidationFacet.syncGracePeriod.selector; vm.stopPrank(); return (address(liquidationFacet), selectors); }