diff --git a/packages/contracts/contracts/ActivePool.sol b/packages/contracts/contracts/ActivePool.sol index 8e600d470..bdb55c215 100644 --- a/packages/contracts/contracts/ActivePool.sol +++ b/packages/contracts/contracts/ActivePool.sol @@ -346,7 +346,7 @@ contract ActivePool is IActivePool, ERC3156FlashLender, ReentrancyGuard, BaseMat uint256 _FeeRecipientColl = FeeRecipientColl; require(_FeeRecipientColl >= _shares, "ActivePool: Insufficient fee recipient coll"); - ICdpManagerData(cdpManagerAddress).applyPendingGlobalState(); + ICdpManagerData(cdpManagerAddress).syncPendingGlobalState(); unchecked { _FeeRecipientColl -= _shares; @@ -368,7 +368,7 @@ contract ActivePool is IActivePool, ERC3156FlashLender, ReentrancyGuard, BaseMat uint256 balance = IERC20(token).balanceOf(address(this)); require(amount <= balance, "ActivePool: Attempt to sweep more than balance"); - ICdpManagerData(cdpManagerAddress).applyPendingGlobalState(); + ICdpManagerData(cdpManagerAddress).syncPendingGlobalState(); address cachedFeeRecipientAddress = feeRecipientAddress; // Saves an SLOAD @@ -383,7 +383,7 @@ contract ActivePool is IActivePool, ERC3156FlashLender, ReentrancyGuard, BaseMat "ActivePool: Cannot set fee recipient to zero address" ); - ICdpManagerData(cdpManagerAddress).applyPendingGlobalState(); + ICdpManagerData(cdpManagerAddress).syncPendingGlobalState(); feeRecipientAddress = _feeRecipientAddress; emit FeeRecipientAddressChanged(_feeRecipientAddress); @@ -392,7 +392,7 @@ contract ActivePool is IActivePool, ERC3156FlashLender, ReentrancyGuard, BaseMat function setFeeBps(uint _newFee) external requiresAuth { require(_newFee <= MAX_FEE_BPS, "ERC3156FlashLender: _newFee should <= MAX_FEE_BPS"); - ICdpManagerData(cdpManagerAddress).applyPendingGlobalState(); + ICdpManagerData(cdpManagerAddress).syncPendingGlobalState(); // set new flash fee uint _oldFee = feeBps; @@ -401,7 +401,7 @@ contract ActivePool is IActivePool, ERC3156FlashLender, ReentrancyGuard, BaseMat } function setFlashLoansPaused(bool _paused) external requiresAuth { - ICdpManagerData(cdpManagerAddress).applyPendingGlobalState(); + ICdpManagerData(cdpManagerAddress).syncPendingGlobalState(); flashLoansPaused = _paused; emit FlashLoansPaused(msg.sender, _paused); diff --git a/packages/contracts/contracts/BorrowerOperations.sol b/packages/contracts/contracts/BorrowerOperations.sol index a98348469..26985d81f 100644 --- a/packages/contracts/contracts/BorrowerOperations.sol +++ b/packages/contracts/contracts/BorrowerOperations.sol @@ -390,12 +390,28 @@ contract BorrowerOperations is In normal mode, ICR must be greater thatn MCR Additionally, the new system TCR after the CDPs addition must be >CCR */ + uint newTCR = _getNewTCRFromCdpChange(vars.netColl, true, vars.debt, true, vars.price); if (isRecoveryMode) { _requireICRisAboveCCR(vars.ICR); + + // == Grace Period == // + // We are in RM, Edge case is Depositing Coll could exit RM + // We check with newTCR + if (newTCR < CCR) { + // Notify RM + cdpManager.notifyStartGracePeriod(newTCR); + } else { + // Notify Back to Normal Mode + cdpManager.notifyEndGracePeriod(newTCR); + } } else { _requireICRisAboveMCR(vars.ICR); - uint newTCR = _getNewTCRFromCdpChange(vars.netColl, true, vars.debt, true, vars.price); // bools: coll increase, debt increase _requireNewTCRisAboveCCR(newTCR); + + // == Grace Period == // + // We are not in RM, no edge case, we always stay above RM + // Always Notify Back to Normal Mode + cdpManager.notifyEndGracePeriod(newTCR); } // Set the cdp struct's properties @@ -458,6 +474,10 @@ contract BorrowerOperations is ); _requireNewTCRisAboveCCR(newTCR); + // == Grace Period == // + // By definition we are not in RM, notify CDPManager to ensure "Glass is on" + cdpManager.notifyEndGracePeriod(newTCR); + cdpManager.removeStake(_cdpId); // We already verified msg.sender is the borrower @@ -602,7 +622,7 @@ contract BorrowerOperations is uint _collWithdrawal, bool _isDebtIncrease, LocalVariables_adjustCdp memory _vars - ) internal view { + ) internal { /* *In Recovery Mode, only allow: * @@ -617,23 +637,41 @@ contract BorrowerOperations is * - The new ICR is above MCR * - The adjustment won't pull the TCR below CCR */ + + _vars.newTCR = _getNewTCRFromCdpChange( + collateral.getPooledEthByShares(_vars.collChange), + _vars.isCollIncrease, + _vars.netDebtChange, + _isDebtIncrease, + _vars.price + ); + if (_isRecoveryMode) { _requireNoCollWithdrawal(_collWithdrawal); if (_isDebtIncrease) { _requireICRisAboveCCR(_vars.newICR); _requireNewICRisAboveOldICR(_vars.newICR, _vars.oldICR); } + + // == Grace Period == // + // We are in RM, Edge case is Depositing Coll could exit RM + // We check with newTCR + if (_vars.newTCR < CCR) { + // Notify RM + cdpManager.notifyStartGracePeriod(_vars.newTCR); + } else { + // Notify Back to Normal Mode + cdpManager.notifyEndGracePeriod(_vars.newTCR); + } } else { // if Normal Mode _requireICRisAboveMCR(_vars.newICR); - _vars.newTCR = _getNewTCRFromCdpChange( - collateral.getPooledEthByShares(_vars.collChange), - _vars.isCollIncrease, - _vars.netDebtChange, - _isDebtIncrease, - _vars.price - ); _requireNewTCRisAboveCCR(_vars.newTCR); + + // == Grace Period == // + // We are not in RM, no edge case, we always stay above RM + // Always Notify Back to Normal Mode + cdpManager.notifyEndGracePeriod(_vars.newTCR); } } diff --git a/packages/contracts/contracts/CdpManager.sol b/packages/contracts/contracts/CdpManager.sol index 914ab0f2f..cf40a1368 100644 --- a/packages/contracts/contracts/CdpManager.sol +++ b/packages/contracts/contracts/CdpManager.sol @@ -181,21 +181,33 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { if (newDebt == 0) { // No debt remains, close CDP // No debt left in the Cdp, therefore the cdp gets closed + { + address _borrower = sortedCdps.getOwnerAddress(_redeemColFromCdp._cdpId); + uint _liquidatorRewardShares = Cdps[_redeemColFromCdp._cdpId].liquidatorRewardShares; + + singleRedemption.collSurplus = newColl; // Collateral surplus processed on full redemption + singleRedemption.liquidatorRewardShares = _liquidatorRewardShares; + singleRedemption.fullRedemption = true; + + _closeCdpByRedemption( + _redeemColFromCdp._cdpId, + 0, + newColl, + _liquidatorRewardShares, + _borrower + ); - address _borrower = sortedCdps.getOwnerAddress(_redeemColFromCdp._cdpId); - _redeemCloseCdp(_redeemColFromCdp._cdpId, 0, newColl, _borrower); - singleRedemption.fullRedemption = true; - - emit CdpUpdated( - _redeemColFromCdp._cdpId, - _borrower, - _oldDebtAndColl.entireDebt, - _oldDebtAndColl.entireColl, - 0, - 0, - 0, - CdpOperation.redeemCollateral - ); + emit CdpUpdated( + _redeemColFromCdp._cdpId, + _borrower, + _oldDebtAndColl.entireDebt, + _oldDebtAndColl.entireColl, + 0, + 0, + 0, + CdpOperation.redeemCollateral + ); + } } else { // Debt remains, reinsert CDP uint newNICR = LiquityMath._computeNominalCR(newColl, newDebt); @@ -249,14 +261,13 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { * The debt recorded on the cdp's struct is zero'd elswhere, in _closeCdp. * Any surplus stETH left in the cdp, is sent to the Coll surplus pool, and can be later claimed by the borrower. */ - function _redeemCloseCdp( + function _closeCdpByRedemption( bytes32 _cdpId, // TODO: Remove? uint _EBTC, - uint _stEth, + uint _collSurplus, + uint _liquidatorRewardShares, address _borrower ) internal { - uint _liquidatorRewardShares = Cdps[_cdpId].liquidatorRewardShares; - _removeStake(_cdpId); _closeCdpWithoutRemovingSortedCdps(_cdpId, Status.closedByRedemption); @@ -264,31 +275,35 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { activePool.decreaseEBTCDebt(_EBTC); // Register stETH surplus from upcoming transfers of stETH collateral and liquidator reward shares - collSurplusPool.accountSurplus(_borrower, _stEth + _liquidatorRewardShares); + collSurplusPool.accountSurplus(_borrower, _collSurplus + _liquidatorRewardShares); // CEI: send stETH coll and liquidator reward shares from Active Pool to CollSurplus Pool activePool.sendStEthCollAndLiquidatorReward( address(collSurplusPool), - _stEth, + _collSurplus, _liquidatorRewardShares ); } + /// @notice Returns true if the CdpId specified is the lowest-ICR Cdp in the linked list that still has MCR > ICR + /// @dev Returns false if the specified CdpId hint is blank + /// @dev Returns false if the specified CdpId hint doesn't exist in the list + /// @dev Returns false if the ICR of the specified CdpId is < MCR + /// @dev Returns true if the specified CdpId is not blank, exists in the list, has an ICR > MCR, and the next lower Cdp in the list is either blank or has an ICR < MCR. function _isValidFirstRedemptionHint( - ISortedCdps _sortedCdps, bytes32 _firstRedemptionHint, uint _price ) internal view returns (bool) { if ( - _firstRedemptionHint == _sortedCdps.nonExistId() || - !_sortedCdps.contains(_firstRedemptionHint) || + _firstRedemptionHint == sortedCdps.nonExistId() || + !sortedCdps.contains(_firstRedemptionHint) || getCurrentICR(_firstRedemptionHint, _price) < MCR ) { return false; } - bytes32 nextCdp = _sortedCdps.getNext(_firstRedemptionHint); - return nextCdp == _sortedCdps.nonExistId() || getCurrentICR(nextCdp, _price) < MCR; + bytes32 nextCdp = sortedCdps.getNext(_firstRedemptionHint); + return nextCdp == sortedCdps.nonExistId() || getCurrentICR(nextCdp, _price) < MCR; } /** @@ -338,15 +353,25 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { _requireValidMaxFeePercentage(_maxFeePercentage); _requireAfterBootstrapPeriod(); - applyPendingGlobalState(); + _applyPendingGlobalState(); // Apply state, we will syncGracePeriod at end of function totals.price = priceFeed.fetchPrice(); - _requireTCRoverMCR(totals.price); + { + ( + uint tcrAtStart, + uint totalCollSharesAtStart, + uint totalEBTCSupplyAtStart + ) = _getTCRWithTotalCollAndDebt(totals.price); + totals.tcrAtStart = tcrAtStart; + totals.totalCollSharesAtStart = totalCollSharesAtStart; + totals.totalEBTCSupplyAtStart = totalEBTCSupplyAtStart; + } + + _requireTCRoverMCR(totals.price, totals.tcrAtStart); _requireAmountGreaterThanZero(_EBTCamount); require(redemptionsPaused == false, "CdpManager: Redemptions Paused"); - totals.totalEBTCSupplyAtStart = _getEntireSystemDebt(); _requireEBTCBalanceCoversRedemptionAndWithinSupply( ebtcToken, msg.sender, @@ -358,7 +383,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { address currentBorrower; bytes32 _cId = _firstRedemptionHint; - if (_isValidFirstRedemptionHint(sortedCdps, _firstRedemptionHint, totals.price)) { + if (_isValidFirstRedemptionHint(_firstRedemptionHint, totals.price)) { currentBorrower = sortedCdps.existCdpOwners(_firstRedemptionHint); } else { _cId = sortedCdps.getLast(); @@ -375,16 +400,17 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { if (_maxIterations == 0) { _maxIterations = type(uint256).max; } + bytes32 _firstRedeemed = _cId; bytes32 _lastRedeemed = _cId; - uint _fullRedeemed; + uint _numCdpsFullyRedeemed; + + /** + Core Redemption Loop + */ while (currentBorrower != address(0) && totals.remainingEBTC > 0 && _maxIterations > 0) { - _maxIterations--; // Save the address of the Cdp preceding the current one, before potentially modifying the list { - bytes32 _nextId = sortedCdps.getPrev(_cId); - address nextUserToCheck = sortedCdps.getOwnerAddress(_nextId); - _applyPendingState(_cId); LocalVariables_RedeemCollateralFromCdp @@ -405,25 +431,32 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { totals.totalEBTCToRedeem = totals.totalEBTCToRedeem + singleRedemption.eBtcToRedeem; totals.totalETHDrawn = totals.totalETHDrawn + singleRedemption.stEthToRecieve; - totals.remainingEBTC = totals.remainingEBTC - singleRedemption.eBtcToRedeem; - currentBorrower = nextUserToCheck; + totals.totalCollSharesSurplus = + totals.totalCollSharesSurplus + + singleRedemption.collSurplus; + if (singleRedemption.fullRedemption) { _lastRedeemed = _cId; - _fullRedeemed = _fullRedeemed + 1; + _numCdpsFullyRedeemed = _numCdpsFullyRedeemed + 1; } + + bytes32 _nextId = sortedCdps.getPrev(_cId); + address nextUserToCheck = sortedCdps.getOwnerAddress(_nextId); + currentBorrower = nextUserToCheck; _cId = _nextId; } + _maxIterations--; } require(totals.totalETHDrawn > 0, "CdpManager: Unable to redeem any amount"); // remove from sortedCdps - if (_fullRedeemed == 1) { + if (_numCdpsFullyRedeemed == 1) { sortedCdps.remove(_firstRedeemed); - } else if (_fullRedeemed > 1) { + } else if (_numCdpsFullyRedeemed > 1) { bytes32[] memory _toRemoveIds = _getCdpIdsToRemove( _lastRedeemed, - _fullRedeemed, + _numCdpsFullyRedeemed, _firstRedeemed ); sortedCdps.batchRemove(_toRemoveIds); @@ -444,6 +477,12 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { totals.ETHToSendToRedeemer = totals.totalETHDrawn - totals.ETHFee; + _syncGracePeriodForGivenValues( + totals.totalCollSharesAtStart - totals.totalETHDrawn - totals.totalCollSharesSurplus, + totals.totalEBTCSupplyAtStart - totals.totalEBTCToRedeem, + totals.price + ); + emit Redemption(_EBTCamount, totals.totalEBTCToRedeem, totals.totalETHDrawn, totals.ETHFee); // Burn the total eBTC that is redeemed @@ -457,9 +496,6 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { // CEI: Send the stETH drawn to the redeemer activePool.sendStEthColl(msg.sender, totals.ETHToSendToRedeemer); - - // TODO: an alternative is we could track a variable on the activePool and avoid the transfer, for claim at-will be feeRecipient - // Then we can avoid the whole feeRecipient contract in every other contract. It can then be governable and switched out. ActivePool can handle sending any extra metadata to the recipient } // --- Helper functions --- @@ -689,13 +725,6 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { // --- 'require' wrapper functions --- - function _requireCallerIsBorrowerOperations() internal view { - require( - msg.sender == borrowerOperationsAddress, - "CdpManager: Caller is not the BorrowerOperations contract" - ); - } - function _requireEBTCBalanceCoversRedemptionAndWithinSupply( IEBTCToken _ebtcToken, address _redeemer, @@ -717,8 +746,8 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { require(_amount > 0, "CdpManager: Amount must be greater than zero"); } - function _requireTCRoverMCR(uint _price) internal view { - require(_getTCR(_price) >= MCR, "CdpManager: Cannot redeem when TCR < MCR"); + function _requireTCRoverMCR(uint _price, uint _TCR) internal view { + require(_TCR >= MCR, "CdpManager: Cannot redeem when TCR < MCR"); } function _requireAfterBootstrapPeriod() internal view { @@ -744,7 +773,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { "CDPManager: new staking reward split exceeds max" ); - applyPendingGlobalState(); + syncPendingGlobalState(); stakingRewardSplit = _stakingRewardSplit; emit StakingRewardSplitSet(_stakingRewardSplit); @@ -760,7 +789,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { "CDPManager: new redemption fee floor is higher than maximum" ); - applyPendingGlobalState(); + syncPendingGlobalState(); redemptionFeeFloor = _redemptionFeeFloor; emit RedemptionFeeFloorSet(_redemptionFeeFloor); @@ -776,7 +805,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { "CDPManager: new minute decay factor out of range" ); - applyPendingGlobalState(); + syncPendingGlobalState(); // decay first according to previous factor _decayBaseRate(); @@ -787,7 +816,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { } function setBeta(uint _beta) external requiresAuth { - applyPendingGlobalState(); + syncPendingGlobalState(); _decayBaseRate(); @@ -796,7 +825,7 @@ contract CdpManager is CdpManagerStorage, ICdpManager, Proxy { } function setRedemptionsPaused(bool _paused) external requiresAuth { - applyPendingGlobalState(); + syncPendingGlobalState(); _decayBaseRate(); redemptionsPaused = _paused; diff --git a/packages/contracts/contracts/CdpManagerStorage.sol b/packages/contracts/contracts/CdpManagerStorage.sol index a26f76921..9987176f8 100644 --- a/packages/contracts/contracts/CdpManagerStorage.sol +++ b/packages/contracts/contracts/CdpManagerStorage.sol @@ -18,6 +18,109 @@ import "./Dependencies/AuthNoOwner.sol"; @dev Shared functions were also added here to de-dup code */ contract CdpManagerStorage is LiquityBase, ReentrancyGuard, ICdpManagerData, AuthNoOwner { + // TODO: IMPROVE + // NOTE: No packing cause it's the last var, no need for u64 + uint128 public constant UNSET_TIMESTAMP = type(uint128).max; + uint128 public constant MINIMUM_GRACE_PERIOD = 15 minutes; + + // TODO: IMPROVE THIS!!! + uint128 public lastGracePeriodStartTimestamp = UNSET_TIMESTAMP; // use max to signify + uint128 public recoveryModeGracePeriod = MINIMUM_GRACE_PERIOD; + + // TODO: Pitfal is fee split // NOTE: Solved by calling `syncGracePeriod` on external operations from BO + + /// @notice Start the recovery mode grace period, if the system is in RM and the grace period timestamp has not already been set + /// @dev Trusted function to allow BorrowerOperations actions to set RM Grace Period + /// @dev Assumes BorrowerOperations has correctly calculated and passed in the new system TCR + /// @dev To maintain CEI compliance we use this trusted function + function notifyStartGracePeriod(uint256 tcr) external { + _requireCallerIsBorrowerOperations(); + _startGracePeriod(tcr); + } + + /// @notice End the recovery mode grace period, if the system is no longer in RM + /// @dev Trusted function to allow BorrowerOperations actions to set RM Grace Period + /// @dev Assumes BorrowerOperations has correctly calculated and passed in the new system TCR + /// @dev To maintain CEI compliance we use this trusted function + function notifyEndGracePeriod(uint256 tcr) external { + _requireCallerIsBorrowerOperations(); + _endGracePeriod(tcr); + } + + /// @dev Internal notify called by Redemptions and Liquidations + /// @dev Specified TCR is emitted for notification pruposes regardless of whether the Grace Period timestamp is set + function _startGracePeriod(uint256 _tcr) internal { + emit TCRNotified(_tcr); + + if (lastGracePeriodStartTimestamp == UNSET_TIMESTAMP) { + lastGracePeriodStartTimestamp = uint128(block.timestamp); + + emit GracePeriodStart(); + } + } + + /// @notice Clear RM Grace Period timestamp if it has been set + /// @notice No input validation, calling function must confirm that the system is not in recovery mode to be valid + /// @dev Specified TCR is emitted for notification pruposes regardless of whether the Grace Period timestamp is set + /// @dev Internal notify called by Redemptions and Liquidations + function _endGracePeriod(uint256 _tcr) internal { + emit TCRNotified(_tcr); + + if (lastGracePeriodStartTimestamp != UNSET_TIMESTAMP) { + lastGracePeriodStartTimestamp = UNSET_TIMESTAMP; + + emit GracePeriodEnd(); + } + } + + /// TODO: obv optimizations + function syncGracePeriod() public { + uint256 price = priceFeed.fetchPrice(); + uint256 tcr = _getTCR(price); + bool isRecoveryMode = _checkRecoveryModeForTCR(tcr); + + if (isRecoveryMode) { + _startGracePeriod(tcr); + } else { + _endGracePeriod(tcr); + } + } + + /// @dev Set RM grace period based on specified system collShares, system debt, and price + /// @dev Variant for internal use in redemptions and liquidations + function _syncGracePeriodForGivenValues( + uint systemCollShares, + uint systemDebt, + uint price + ) internal { + // Compute TCR with specified values + uint newTCR = LiquityMath._computeCR( + collateral.getPooledEthByShares(systemCollShares), + systemDebt, + price + ); + + if (newTCR < CCR) { + // Notify system is in RM + _startGracePeriod(newTCR); + } else { + // Notify system is outside RM + _endGracePeriod(newTCR); + } + } + + /// @notice Set grace period duratin + /// @notice Permissioned governance function, must set grace period duration above hardcoded minimum + /// @param _gracePeriod new grace period duration, in seconds + function setGracePeriod(uint128 _gracePeriod) external requiresAuth { + require( + _gracePeriod >= MINIMUM_GRACE_PERIOD, + "CdpManager: Grace period below minimum duration" + ); + recoveryModeGracePeriod = _gracePeriod; + emit GracePeriodSet(_gracePeriod); + } + string public constant NAME = "CdpManager"; // --- Connected contract declarations --- @@ -361,7 +464,13 @@ contract CdpManagerStorage is LiquityBase, ReentrancyGuard, ICdpManagerData, Aut // Claim split fee if there is staking-reward coming // and update global index & fee-per-unit variables - function applyPendingGlobalState() public { + /// @dev BO can call this without trigggering a + function applyPendingGlobalState() external { + _requireCallerIsBorrowerOperations(); + _applyPendingGlobalState(); + } + + function _applyPendingGlobalState() internal { (uint _oldIndex, uint _newIndex) = _syncIndex(); if (_newIndex > _oldIndex && totalStakes > 0) { (uint _feeTaken, uint _deltaFeePerUnit, uint _perUnitError) = calcFeeUponStakingReward( @@ -373,6 +482,13 @@ contract CdpManagerStorage is LiquityBase, ReentrancyGuard, ICdpManagerData, Aut } } + /// @notice Claim Fee Split, toggles Grace Period accordingly + /// @notice Call this if you want to accrue feeSplit + function syncPendingGlobalState() public { + _applyPendingGlobalState(); // Apply // Could trigger RM + syncGracePeriod(); // Synch Grace Period + } + // Update the global index via collateral token function _syncIndex() internal returns (uint, uint) { uint _oldIndex = stFPPSg; @@ -434,7 +550,7 @@ contract CdpManagerStorage is LiquityBase, ReentrancyGuard, ICdpManagerData, Aut // whenever there is a CDP modification operation, // such as opening, closing, adding collateral, repaying debt, or liquidating // OR Should we utilize some bot-keeper to work the routine job at fixed interval? - applyPendingGlobalState(); + _applyPendingGlobalState(); uint _oldPerUnitCdp = stFeePerUnitcdp[_cdpId]; uint _stFeePerUnitg = stFeePerUnitg; @@ -504,6 +620,13 @@ contract CdpManagerStorage is LiquityBase, ReentrancyGuard, ICdpManagerData, Aut ); } + function _requireCallerIsBorrowerOperations() internal view { + require( + msg.sender == borrowerOperationsAddress, + "CdpManager: Caller is not the BorrowerOperations contract" + ); + } + // --- Helper functions --- // Return the nominal collateral ratio (ICR) of a given Cdp, without the price. diff --git a/packages/contracts/contracts/Interfaces/ICdpManagerData.sol b/packages/contracts/contracts/Interfaces/ICdpManagerData.sol index a326f2a41..e7779531e 100644 --- a/packages/contracts/contracts/Interfaces/ICdpManagerData.sol +++ b/packages/contracts/contracts/Interfaces/ICdpManagerData.sol @@ -6,10 +6,11 @@ import "./ICollSurplusPool.sol"; import "./IEBTCToken.sol"; import "./ISortedCdps.sol"; import "./IActivePool.sol"; +import "./IRecoveryModeGracePeriod.sol"; import "../Dependencies/ICollateralTokenOracle.sol"; // Common interface for the Cdp Manager. -interface ICdpManagerData { +interface ICdpManagerData is IRecoveryModeGracePeriod { // --- Events --- event LiquidationLibraryAddressChanged(address _liquidationLibraryAddress); @@ -194,16 +195,21 @@ interface ICdpManagerData { uint remainingEBTC; uint totalEBTCToRedeem; uint totalETHDrawn; + uint totalCollSharesSurplus; uint ETHFee; uint ETHToSendToRedeemer; uint decayedBaseRate; uint price; uint totalEBTCSupplyAtStart; + uint totalCollSharesAtStart; + uint tcrAtStart; } struct SingleRedemptionValues { uint eBtcToRedeem; uint stEthToRecieve; + uint collSurplus; + uint liquidatorRewardShares; bool cancelledPartial; bool fullRedemption; } @@ -223,7 +229,9 @@ interface ICdpManagerData { uint256 _prevIndex ) external view returns (uint256, uint256, uint256); - function applyPendingGlobalState() external; + function applyPendingGlobalState() external; // Accrues StEthFeeSplit without influencing Grace Period + + function syncPendingGlobalState() external; // Accrues StEthFeeSplit and influences Grace Period function getAccumulatedFeeSplitApplied( bytes32 _cdpId, diff --git a/packages/contracts/contracts/Interfaces/IRecoveryModeGracePeriod.sol b/packages/contracts/contracts/Interfaces/IRecoveryModeGracePeriod.sol new file mode 100644 index 000000000..4c546b5ab --- /dev/null +++ b/packages/contracts/contracts/Interfaces/IRecoveryModeGracePeriod.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +// Interface for State Updates that can trigger RM Liquidations +interface IRecoveryModeGracePeriod { + event TCRNotified(uint TCR); /// NOTE: Mostly for debugging to ensure synch + + // NOTE: Ts is implicit in events (it's added by GETH) + event GracePeriodStart(); + event GracePeriodEnd(); + event GracePeriodSet(uint256 _recoveryModeGracePeriod); + + function syncGracePeriod() external; + + function notifyStartGracePeriod(uint256 tcr) external; + + function notifyEndGracePeriod(uint256 tcr) external; +} diff --git a/packages/contracts/contracts/LiquidationLibrary.sol b/packages/contracts/contracts/LiquidationLibrary.sol index 1e29adb94..a524dda7d 100644 --- a/packages/contracts/contracts/LiquidationLibrary.sol +++ b/packages/contracts/contracts/LiquidationLibrary.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.17; - import "./Interfaces/ICdpManagerData.sol"; import "./Interfaces/ICollSurplusPool.sol"; import "./Interfaces/IEBTCToken.sol"; @@ -65,10 +64,24 @@ contract LiquidationLibrary is CdpManagerStorage { uint256 _ICR = getCurrentICR(_cdpId, _price); (uint _TCR, uint systemColl, uint systemDebt) = _getTCRWithTotalCollAndDebt(_price); - require( - _ICR < MCR || (_TCR < CCR && _ICR < _TCR), - "CdpManager: ICR is not below liquidation threshold in current mode" - ); + // If CDP is above MCR + if (_ICR >= MCR) { + // We must be in RM + require( + _TCR < CCR, + "CdpManager: ICR is not below liquidation threshold in current mode" + ); + + // == Grace Period == // + require( + lastGracePeriodStartTimestamp != UNSET_TIMESTAMP, + "CdpManager: Recovery Mode grace period not started" + ); + require( + block.timestamp > lastGracePeriodStartTimestamp + recoveryModeGracePeriod, + "CdpManager: Recovery mode grace period still in effect" + ); + } // Implicit Else Case, Implies ICR < MRC, meaning the CDP is liquidatable bool _recoveryModeAtStart = _TCR < CCR ? true : false; LocalVar_InternalLiquidate memory _liqState = LocalVar_InternalLiquidate( @@ -111,36 +124,48 @@ contract LiquidationLibrary is CdpManagerStorage { LocalVar_InternalLiquidate memory _liqState, LocalVar_RecoveryLiquidate memory _recoveryState ) internal { - uint256 totalDebtToBurn; - uint256 totalColToSend; - uint256 totalDebtToRedistribute; - uint256 totalColReward; + LiquidationValues memory liquidationValues; + + uint256 startingSystemDebt = _recoveryState.entireSystemDebt; + uint256 startingSystemColl = _recoveryState.entireSystemColl; if (_liqState._partialAmount == 0) { ( - totalDebtToBurn, - totalColToSend, - totalDebtToRedistribute, - totalColReward + liquidationValues.debtToOffset, + liquidationValues.totalCollToSendToLiquidator, + liquidationValues.debtToRedistribute, + liquidationValues.collReward, + liquidationValues.collSurplus ) = _liquidateCDPByExternalLiquidator(_liqState, _recoveryState); } else { - (totalDebtToBurn, totalColToSend) = _liquidateCDPPartially(_liqState); - if (totalColToSend == 0 && totalDebtToBurn == 0) { + ( + liquidationValues.debtToOffset, + liquidationValues.totalCollToSendToLiquidator + ) = _liquidateCDPPartially(_liqState); + if ( + liquidationValues.totalCollToSendToLiquidator == 0 && + liquidationValues.debtToOffset == 0 + ) { // retry with fully liquidation ( - totalDebtToBurn, - totalColToSend, - totalDebtToRedistribute, - totalColReward + liquidationValues.debtToOffset, + liquidationValues.totalCollToSendToLiquidator, + liquidationValues.debtToRedistribute, + liquidationValues.collReward, + liquidationValues.collSurplus ) = _liquidateCDPByExternalLiquidator(_liqState, _recoveryState); } } _finalizeExternalLiquidation( - totalDebtToBurn, - totalColToSend, - totalDebtToRedistribute, - totalColReward + liquidationValues.debtToOffset, + liquidationValues.totalCollToSendToLiquidator, + liquidationValues.debtToRedistribute, + liquidationValues.collReward, + liquidationValues.collSurplus, + startingSystemColl, + startingSystemDebt, + _liqState._price ); } @@ -149,7 +174,7 @@ contract LiquidationLibrary is CdpManagerStorage { function _liquidateCDPByExternalLiquidator( LocalVar_InternalLiquidate memory _liqState, LocalVar_RecoveryLiquidate memory _recoveryState - ) private returns (uint256, uint256, uint256, uint256) { + ) private returns (uint256, uint256, uint256, uint256, uint256) { if (_liqState._recoveryModeAtStart) { LocalVar_RecoveryLiquidate memory _outputState = _liquidateSingleCDPInRecoveryMode( _recoveryState @@ -164,7 +189,8 @@ contract LiquidationLibrary is CdpManagerStorage { _outputState.totalDebtToBurn, _outputState.totalColToSend, _outputState.totalDebtToRedistribute, - _outputState.totalColReward + _outputState.totalColReward, + _outputState.totalColSurplus ); } else { LocalVar_InternalLiquidate memory _outputState = _liquidateSingleCDPInNormalMode( @@ -174,7 +200,8 @@ contract LiquidationLibrary is CdpManagerStorage { _outputState.totalDebtToBurn, _outputState.totalColToSend, _outputState.totalDebtToRedistribute, - _outputState.totalColReward + _outputState.totalColReward, + _outputState.totalColSurplus ); } } @@ -415,7 +442,7 @@ contract LiquidationLibrary is CdpManagerStorage { uint _cnt; for (uint i = 0; i < _n && _cdpId != _first; ++i) { uint _icr = getCurrentICR(_cdpId, _price); - bool _liquidatable = _recovery ? (_icr < MCR || _icr < _TCR) : _icr < MCR; + bool _liquidatable = _canLiquidateInCurrentMode(_recovery, _icr, _TCR); if (_liquidatable && Cdps[_cdpId].status == Status.active) { _cnt += 1; } @@ -428,7 +455,7 @@ contract LiquidationLibrary is CdpManagerStorage { uint _j; for (uint i = 0; i < _n && _cdpId != _first; ++i) { uint _icr = getCurrentICR(_cdpId, _price); - bool _liquidatable = _recovery ? (_icr < MCR || _icr < _TCR) : _icr < MCR; + bool _liquidatable = _canLiquidateInCurrentMode(_recovery, _icr, _TCR); if (_liquidatable && Cdps[_cdpId].status == Status.active) { _array[_cnt - _j - 1] = _cdpId; _j += 1; @@ -493,13 +520,23 @@ contract LiquidationLibrary is CdpManagerStorage { uint256 totalDebtToBurn, uint256 totalColToSend, uint256 totalDebtToRedistribute, - uint256 totalColReward + uint256 totalColReward, + uint256 totalColSurplus, + uint256 systemInitialCollShares, + uint256 systemInitialDebt, + uint256 price ) internal { // update the staking and collateral snapshots _updateSystemSnapshots_excludeCollRemainder(totalColToSend); emit Liquidation(totalDebtToBurn, totalColToSend, totalColReward); + _syncGracePeriodForGivenValues( + systemInitialCollShares - totalColToSend - totalColSurplus, + systemInitialDebt - totalDebtToBurn, + price + ); + // redistribute debt if any if (totalDebtToRedistribute > 0) { _redistributeDebt(totalDebtToRedistribute); @@ -564,7 +601,7 @@ contract LiquidationLibrary is CdpManagerStorage { LiquidationTotals memory totals; // taking fee to avoid accounted for the calculation of the TCR - applyPendingGlobalState(); + _applyPendingGlobalState(); vars.price = priceFeed.fetchPrice(); (uint _TCR, uint systemColl, uint systemDebt) = _getTCRWithTotalCollAndDebt(vars.price); @@ -574,6 +611,7 @@ contract LiquidationLibrary is CdpManagerStorage { bytes32[] memory _batchedCdps; if (vars.recoveryModeAtStart) { _batchedCdps = _sequenceLiqToBatchLiq(_n, true, vars.price); + require(_batchedCdps.length > 0, "LiquidationLibrary: nothing to liquidate"); totals = _getTotalFromBatchLiquidate_RecoveryMode( vars.price, systemColl, @@ -584,6 +622,7 @@ contract LiquidationLibrary is CdpManagerStorage { } else { // if !vars.recoveryModeAtStart _batchedCdps = _sequenceLiqToBatchLiq(_n, false, vars.price); + require(_batchedCdps.length > 0, "LiquidationLibrary: nothing to liquidate"); totals = _getTotalsFromBatchLiquidate_NormalMode(vars.price, _TCR, _batchedCdps, true); } @@ -598,7 +637,11 @@ contract LiquidationLibrary is CdpManagerStorage { totals.totalDebtToOffset, totals.totalCollToSendToLiquidator, totals.totalDebtToRedistribute, - totals.totalCollReward + totals.totalCollReward, + totals.totalCollSurplus, + systemColl, + systemDebt, + vars.price ); } @@ -685,7 +728,7 @@ contract LiquidationLibrary is CdpManagerStorage { LiquidationTotals memory totals; // taking fee to avoid accounted for the calculation of the TCR - applyPendingGlobalState(); + _applyPendingGlobalState(); vars.price = priceFeed.fetchPrice(); (uint _TCR, uint systemColl, uint systemDebt) = _getTCRWithTotalCollAndDebt(vars.price); @@ -716,7 +759,11 @@ contract LiquidationLibrary is CdpManagerStorage { totals.totalDebtToOffset, totals.totalCollToSendToLiquidator, totals.totalDebtToRedistribute, - totals.totalCollReward + totals.totalCollReward, + totals.totalCollSurplus, + systemColl, + systemDebt, + vars.price ); } @@ -752,7 +799,10 @@ contract LiquidationLibrary is CdpManagerStorage { if (vars.cdpId != bytes32(0) && Cdps[vars.cdpId].status == Status.active) { vars.ICR = getCurrentICR(vars.cdpId, _price); - if (!vars.backToNormalMode && (vars.ICR < MCR || vars.ICR < _TCR)) { + if ( + !vars.backToNormalMode && + (vars.ICR < MCR || canLiquidateRecoveryMode(vars.ICR, _TCR)) + ) { vars.price = _price; _applyAccumulatedFeeSplit(vars.cdpId); _getLiquidationValuesRecoveryMode( @@ -970,4 +1020,25 @@ contract LiquidationLibrary is CdpManagerStorage { "LiquidationLibrary: Coll remaining in partially liquidated CDP must be >= minimum" ); } + + // Can liquidate in RM if ICR < TCR AND Enough time has passed + function canLiquidateRecoveryMode(uint256 icr, uint256 tcr) public view returns (bool) { + // ICR < TCR and we have waited enough + return + icr < tcr && + lastGracePeriodStartTimestamp != UNSET_TIMESTAMP && + block.timestamp > lastGracePeriodStartTimestamp + recoveryModeGracePeriod; + } + + function _canLiquidateInCurrentMode( + bool _recovery, + uint256 _icr, + uint256 _TCR + ) internal view returns (bool) { + bool _liquidatable = _recovery + ? (_icr < MCR || canLiquidateRecoveryMode(_icr, _TCR)) + : _icr < MCR; + + return _liquidatable; + } } diff --git a/packages/contracts/foundry_test/BaseFixture.sol b/packages/contracts/foundry_test/BaseFixture.sol index 892011203..bd2f28492 100644 --- a/packages/contracts/foundry_test/BaseFixture.sol +++ b/packages/contracts/foundry_test/BaseFixture.sol @@ -18,10 +18,11 @@ import {CollateralTokenTester} from "../contracts/TestContracts/CollateralTokenT import {Governor} from "../contracts/Governor.sol"; import {EBTCDeployer} from "../contracts/EBTCDeployer.sol"; import {Utilities} from "./utils/Utilities.sol"; +import {LogUtils} from "./utils/LogUtils.sol"; import {BytecodeReader} from "./utils/BytecodeReader.sol"; import {IERC3156FlashLender} from "../contracts/Interfaces/IERC3156FlashLender.sol"; -contract eBTCBaseFixture is Test, BytecodeReader { +contract eBTCBaseFixture is Test, BytecodeReader, LogUtils { uint internal constant FEE = 5e15; // 0.5% uint256 internal constant MINIMAL_COLLATERAL_RATIO = 110e16; // MCR: 110% uint public constant CCR = 125e16; // 125% @@ -32,6 +33,7 @@ contract eBTCBaseFixture is Test, BytecodeReader { uint internal constant AMOUNT_OF_USERS = 100; uint internal constant AMOUNT_OF_CDPS = 3; uint internal DECIMAL_PRECISION = 1e18; + bytes32 public constant ZERO_ID = bytes32(0); uint internal constant MAX_BPS = 10000; @@ -52,6 +54,8 @@ contract eBTCBaseFixture is Test, BytecodeReader { bytes4 private constant SET_BETA_SIG = bytes4(keccak256(bytes("setBeta(uint256)"))); bytes4 private constant SET_REDEMPETIONS_PAUSED_SIG = bytes4(keccak256(bytes("setRedemptionsPaused(bool)"))); + bytes4 private constant SET_GRACE_PERIOD_SIG = + bytes4(keccak256(bytes("setGracePeriod(uint128)"))); // EBTCToken bytes4 public constant MINT_SIG = bytes4(keccak256(bytes("mint(address,uint256)"))); @@ -330,6 +334,7 @@ contract eBTCBaseFixture is Test, BytecodeReader { authority.setRoleCapability(3, address(cdpManager), SET_MINUTE_DECAY_FACTOR_SIG, true); authority.setRoleCapability(3, address(cdpManager), SET_BETA_SIG, true); authority.setRoleCapability(3, address(cdpManager), SET_REDEMPETIONS_PAUSED_SIG, true); + authority.setRoleCapability(3, address(cdpManager), SET_GRACE_PERIOD_SIG, true); authority.setRoleCapability(4, address(priceFeedMock), SET_FALLBACK_CALLER_SIG, true); @@ -443,6 +448,48 @@ contract eBTCBaseFixture is Test, BytecodeReader { assertTrue(cdpManager.stFeePerUnitcdp(cdpId) == 0); } + function _printSystemState() internal { + uint price = priceFeedMock.fetchPrice(); + console.log("== Core State =="); + console.log("systemCollShares :", activePool.getStEthColl()); + console.log( + "systemStEthBalance :", + collateral.getPooledEthByShares(activePool.getStEthColl()) + ); + console.log("systemDebt :", activePool.getEBTCDebt()); + console.log("TCR :", cdpManager.getTCR(price)); + console.log("stEthLiveIndex :", collateral.getPooledEthByShares(DECIMAL_PRECISION)); + console.log("stEthGlobalIndex :", cdpManager.stFPPSg()); + console.log("price :", price); + } + + function _getICR(bytes32 cdpId) internal returns (uint) { + uint price = priceFeedMock.fetchPrice(); + return cdpManager.getCurrentICR(cdpId, price); + } + + function _printAllCdps() internal { + uint price = priceFeedMock.fetchPrice(); + uint numCdps = sortedCdps.getSize(); + bytes32 node = sortedCdps.getLast(); + address borrower = sortedCdps.getOwnerAddress(node); + + while (borrower != address(0)) { + console.log("=== ", bytes32ToString(node)); + console.log("debt (realized) :", cdpManager.getCdpDebt(node)); + console.log("collShares (realized) :", cdpManager.getCdpColl(node)); + console.log("ICR :", cdpManager.getCurrentICR(node, price)); + console.log( + "Percent of System :", + (cdpManager.getCdpColl(node) * DECIMAL_PRECISION) / activePool.getStEthColl() + ); + console.log(""); + + node = sortedCdps.getPrev(node); + borrower = sortedCdps.getOwnerAddress(node); + } + } + /// @dev Ensure a given CdpId is not in the Sorted Cdps LL. /// @dev a Cdp should only be present in the LL when it is active. function _assertCdpNotInSortedCdps(bytes32 cdpId) internal { @@ -457,4 +504,10 @@ contract eBTCBaseFixture is Test, BytecodeReader { _currentCdpId = sortedCdps.getPrev(_currentCdpId); } } + + // Grace Period, check never reverts so it's safe to use + function _waitUntilRMColldown() internal { + cdpManager.syncGracePeriod(); + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + } } diff --git a/packages/contracts/foundry_test/CDPManager.governance.t.sol b/packages/contracts/foundry_test/CDPManager.governance.t.sol index ca39a614b..d7a47f7b9 100644 --- a/packages/contracts/foundry_test/CDPManager.governance.t.sol +++ b/packages/contracts/foundry_test/CDPManager.governance.t.sol @@ -220,4 +220,149 @@ contract CDPManagerGovernanceTest is eBTCBaseFixture { // Confirm variable set assertEq(cdpManager.beta(), newBeta); } + + function test_CdpManagerSetGracePeriod_Auth(uint128 newGracePeriod) public { + vm.assume(newGracePeriod >= cdpManager.MINIMUM_GRACE_PERIOD()); + (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) = _initSystemInRecoveryMode(); + + uint oldGracePeriod = cdpManager.recoveryModeGracePeriod(); + + address noPermissionsUser = _utils.getNextUserAddress(); + vm.prank(noPermissionsUser); + vm.expectRevert("Auth: UNAUTHORIZED"); + cdpManager.setGracePeriod(newGracePeriod); + } + + function test_CdpManagerSetGracePeriodValid_Succeeds(uint128 newGracePeriod) public { + vm.assume(newGracePeriod >= cdpManager.MINIMUM_GRACE_PERIOD()); + (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) = _initSystemInRecoveryMode(); + + uint oldGracePeriod = cdpManager.recoveryModeGracePeriod(); + + vm.prank(defaultGovernance); + cdpManager.setGracePeriod(newGracePeriod); + + assertEq(cdpManager.recoveryModeGracePeriod(), newGracePeriod); + } + + /// @dev Confirm extending the grace period works + function test_CdpManagerSetGracePeriodValid_IsEnforcedForUnsetGracePeriod( + uint128 newGracePeriod + ) public { + vm.assume(newGracePeriod >= cdpManager.MINIMUM_GRACE_PERIOD() + 2); + vm.assume(newGracePeriod < type(uint128).max / 10); // prevent unrealistic overflow + + (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) = _initSystemInRecoveryMode(); + + uint oldGracePeriod = cdpManager.recoveryModeGracePeriod(); + + vm.prank(defaultGovernance); + cdpManager.setGracePeriod(newGracePeriod); + + assertEq(cdpManager.recoveryModeGracePeriod(), newGracePeriod); + + _confirmGracePeriodNewDurationEnforced( + oldGracePeriod, + newGracePeriod, + whale, + toLiquidateCdpId + ); + } + + function test_CdpManagerSetGracePeriodInvalid_Reverts(uint128 newGracePeriod) public { + vm.assume(newGracePeriod < cdpManager.MINIMUM_GRACE_PERIOD()); + (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) = _initSystemInRecoveryMode(); + + uint oldGracePeriod = cdpManager.recoveryModeGracePeriod(); + + vm.prank(defaultGovernance); + vm.expectRevert("CdpManager: Grace period below minimum duration"); + cdpManager.setGracePeriod(newGracePeriod); + + assertEq(cdpManager.recoveryModeGracePeriod(), oldGracePeriod); + } + + function test_CdpManagerSetGracePeriodInvalid_RevertsAndIsNotEnforcedForUnsetGracePeriod( + uint128 newGracePeriod + ) public { + vm.assume(newGracePeriod < cdpManager.MINIMUM_GRACE_PERIOD()); + (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) = _initSystemInRecoveryMode(); + + uint oldGracePeriod = cdpManager.recoveryModeGracePeriod(); + + vm.prank(defaultGovernance); + vm.expectRevert("CdpManager: Grace period below minimum duration"); + cdpManager.setGracePeriod(newGracePeriod); + + assertEq(cdpManager.recoveryModeGracePeriod(), oldGracePeriod); + } + + /// @dev Assumes newGracePeriod > oldGracePeriod + function _confirmGracePeriodNewDurationEnforced( + uint oldGracePeriod, + uint newGracePeriod, + address actor, + bytes32 toLiquidateCdpId + ) public { + vm.startPrank(actor); + cdpManager.syncGracePeriod(); + uint startTimestamp = block.timestamp; + uint expectedGracePeriodExpiration = cdpManager.recoveryModeGracePeriod() + + cdpManager.lastGracePeriodStartTimestamp(); + + assertEq(startTimestamp, cdpManager.lastGracePeriodStartTimestamp()); + + // Attempt before previous duration, should fail + vm.warp(startTimestamp + oldGracePeriod + 1); + assertLt(block.timestamp, expectedGracePeriodExpiration, "after grace period complete"); + + console.log(1); + + vm.expectRevert("CdpManager: Recovery mode grace period still in effect"); + cdpManager.liquidate(toLiquidateCdpId); + + // Attempt between previous duration and new duration, should fail + vm.warp(startTimestamp + newGracePeriod - 1); + assertLt(block.timestamp, expectedGracePeriodExpiration, "after grace period complete"); + + console.log(2); + + vm.expectRevert("CdpManager: Recovery mode grace period still in effect"); + cdpManager.liquidate(toLiquidateCdpId); + + // Attempt after new duration, should succeed + vm.warp(startTimestamp + newGracePeriod + 1); + assertGe(block.timestamp, expectedGracePeriodExpiration, "before grace period complete"); + + console.log(3); + cdpManager.liquidate(toLiquidateCdpId); + + vm.stopPrank(); + } + + function _initSystemInRecoveryMode() + internal + returns (bytes32 whaleCdpId, bytes32 toLiquidateCdpId, address whale) + { + // Create a whale + whale = _utils.getNextUserAddress(); + + // 2x test price + priceFeedMock.setPrice(2 ether); + uint price = priceFeedMock.fetchPrice(); + + // Open whale CDPs at 220% + toLiquidateCdpId = _openTestCDP(whale, 11.2e18, 10e18); + whaleCdpId = _openTestCDP(whale, 1100.2e18, 1000e18); + + assertEq(cdpManager.getCurrentICR(whaleCdpId, price), 220e16, "unexpected ICR"); + assertEq(cdpManager.getTCR(price), 220e16, "unexpected TCR"); + + // original price + priceFeedMock.setPrice(1 ether); + price = priceFeedMock.fetchPrice(); + + assertEq(cdpManager.getCurrentICR(whaleCdpId, price), 110e16, "unexpected ICR"); + assertEq(cdpManager.getTCR(price), 110e16, "unexpected TCR"); + } } diff --git a/packages/contracts/foundry_test/CDPManager.redemptions.t.sol b/packages/contracts/foundry_test/CDPManager.redemptions.t.sol index baac5b5fc..1d9063a55 100644 --- a/packages/contracts/foundry_test/CDPManager.redemptions.t.sol +++ b/packages/contracts/foundry_test/CDPManager.redemptions.t.sol @@ -9,6 +9,8 @@ contract CDPManagerRedemptionsTest is eBTCBaseInvariants { // Storage array of cdpIDs when impossible to calculate array size bytes32[] cdpIds; uint public mintAmount = 1e18; + uint private ICR_COMPARE_TOLERANCE = 1000000; //in the scale of 1e18 + address payable[] users; function setUp() public override { super.setUp(); @@ -255,6 +257,106 @@ contract CDPManagerRedemptionsTest is eBTCBaseInvariants { vm.stopPrank(); } + function test_SingleRedemptionCollSurplus(uint _toRedeemICR) public { + // setup healthy whale Cdp + // set 1 Cdp that is valid to redeem + // calculate expected collSurplus from redemption of Cdp + // calculate expected system debt after valid redemption + // calculate expected system coll after valid redemption + // fully redeem single Cdp + // borrower of Redeemed Cdp should have expected collSurplus available + // confirm expected system debt and coll + address user = _utils.getNextUserAddress(); + + // ensure redemption ICR falls in reasonable range + vm.assume(_toRedeemICR > cdpManager.MCR()); + vm.assume(_toRedeemICR <= cdpManager.CCR()); + + uint _originalPrice = priceFeedMock.fetchPrice(); + + // ensure there is more than one CDP + _singleCdpSetupWithICR(user, 200e16); + (, bytes32 userCdpid) = _singleCdpSetupWithICR(user, _toRedeemICR); + uint _totalCollBefore = cdpManager.getEntireSystemColl(); + uint _totalDebtBefore = cdpManager.getEntireSystemDebt(); + uint _redeemedDebt = cdpManager.getCdpDebt(userCdpid); + uint _cdpColl = cdpManager.getCdpColl(userCdpid); + uint _cdpLiqReward = cdpManager.getCdpLiquidatorRewardShares(userCdpid); + + // perform redemption + _performRedemption(user, _redeemedDebt, userCdpid, userCdpid); + + { + _checkFullyRedeemedCdp(userCdpid, user, _cdpColl, _redeemedDebt); + _utils.assertApproximateEq( + _totalCollBefore - _cdpColl, + cdpManager.getEntireSystemColl(), + ICR_COMPARE_TOLERANCE + ); + assertEq( + _totalDebtBefore - _redeemedDebt, + cdpManager.getEntireSystemDebt(), + "total debt mismatch after redemption!!!" + ); + } + } + + function test_MultipleRedemptionCollSurplus(uint _toRedeemICR) public { + // setup healthy whale Cdp + // set 3 Cdps that are valid to redeem at same ICR, different borrowers + // calculate expected collSurplus from full redemption of Cdps + // calculate expected system debt after all valid redemptions + // calculate expected system coll after all valid redemptions + // fully redeem 2 Cdps, partially redeem the third + // borrowers of full Redeemed Cdps should have expected collSurplus available + // borrowers of partially redeemed Cdp should have no collSurplus available + // confirm expected system debt and coll + users = _utils.createUsers(3); + + // ensure redemption ICR falls in reasonable range + vm.assume(_toRedeemICR > cdpManager.MCR()); + vm.assume(_toRedeemICR <= cdpManager.CCR()); + + uint _originalPrice = priceFeedMock.fetchPrice(); + + // ensure there is more than one CDP + _singleCdpSetupWithICR(users[0], 200e16); + (, bytes32 userCdpid1) = _singleCdpSetupWithICR(users[0], _toRedeemICR); + (, bytes32 userCdpid2) = _singleCdpSetupWithICR(users[1], _toRedeemICR + 2e16); + (, bytes32 userCdpid3) = _singleCdpSetupWithICR(users[2], _toRedeemICR + 4e16); + uint _totalCollBefore = cdpManager.getEntireSystemColl(); + uint _totalDebtBefore = cdpManager.getEntireSystemDebt(); + uint _cdpDebt1 = cdpManager.getCdpDebt(userCdpid1); + uint _cdpDebt2 = cdpManager.getCdpDebt(userCdpid2); + uint _cdpDebt3 = cdpManager.getCdpDebt(userCdpid3); + uint _cdpColl1 = cdpManager.getCdpColl(userCdpid1); + uint _cdpColl2 = cdpManager.getCdpColl(userCdpid2); + uint _redeemedDebt = _cdpDebt1 + _cdpDebt2 + (_cdpDebt3 / 2); + deal(address(eBTCToken), users[0], _redeemedDebt); // sugardaddy redeemer + + // perform redemption + _performRedemption(users[0], _redeemedDebt, userCdpid1, userCdpid1); + + { + _checkFullyRedeemedCdp(userCdpid1, users[0], _cdpColl1, _cdpDebt1); + _checkFullyRedeemedCdp(userCdpid2, users[1], _cdpColl2, _cdpDebt2); + _checkPartiallyRedeemedCdp(userCdpid3, users[2]); + _utils.assertApproximateEq( + _totalCollBefore - + _cdpColl1 - + _cdpColl2 - + (((_cdpDebt3 * 1e18) / 2) / _originalPrice), + cdpManager.getEntireSystemColl(), + ICR_COMPARE_TOLERANCE + ); + assertEq( + _totalDebtBefore - _redeemedDebt, + cdpManager.getEntireSystemDebt(), + "total debt mismatch after redemption!!!" + ); + } + } + function _singleCdpRedemptionSetup() internal returns (address user, bytes32 userCdpId) { uint debt = 2e17; user = _utils.getNextUserAddress(); @@ -264,4 +366,60 @@ contract CDPManagerRedemptionsTest is eBTCBaseInvariants { eBTCToken.approve(address(cdpManager), type(uint256).max); vm.stopPrank(); } + + function _singleCdpSetupWithICR(address _usr, uint _icr) internal returns (address, bytes32) { + uint _price = priceFeedMock.fetchPrice(); + uint _coll = cdpManager.MIN_NET_COLL() * 2; + uint _debt = (_coll * _price) / _icr; + bytes32 _cdpId = _openTestCDP(_usr, _coll + cdpManager.LIQUIDATOR_REWARD(), _debt); + uint _cdpICR = cdpManager.getCurrentICR(_cdpId, _price); + _utils.assertApproximateEq(_icr, _cdpICR, ICR_COMPARE_TOLERANCE); // in the scale of 1e18 + return (_usr, _cdpId); + } + + function _performRedemption( + address _redeemer, + uint _redeemedDebt, + bytes32 _upperPartialRedemptionHint, + bytes32 _lowerPartialRedemptionHint + ) internal { + (bytes32 firstRedemptionHint, uint partialRedemptionHintNICR, , ) = hintHelpers + .getRedemptionHints(_redeemedDebt, priceFeedMock.fetchPrice(), 0); + vm.prank(_redeemer); + cdpManager.redeemCollateral( + _redeemedDebt, + firstRedemptionHint, + _upperPartialRedemptionHint, + _lowerPartialRedemptionHint, + partialRedemptionHintNICR, + 0, + 1e18 + ); + } + + function _checkFullyRedeemedCdp( + bytes32 _cdpId, + address _cdpOwner, + uint _cdpColl, + uint _cdpDebt + ) internal { + uint _expectedCollSurplus = _cdpColl + + cdpManager.LIQUIDATOR_REWARD() - + ((_cdpDebt * 1e18) / priceFeedMock.fetchPrice()); + assertTrue(sortedCdps.contains(_cdpId) == false); + assertEq( + _expectedCollSurplus, + collSurplusPool.getCollateral(_cdpOwner), + "coll surplus balance mismatch after full redemption!!!" + ); + } + + function _checkPartiallyRedeemedCdp(bytes32 _cdpId, address _cdpOwner) internal { + assertTrue(sortedCdps.contains(_cdpId) == true); + assertEq( + 0, + collSurplusPool.getCollateral(_cdpOwner), + "coll surplus not zero after partial redemption!!!" + ); + } } diff --git a/packages/contracts/foundry_test/CdpManager.Liquidation.t.sol b/packages/contracts/foundry_test/CdpManager.Liquidation.t.sol index af600860f..b7f9c8847 100644 --- a/packages/contracts/foundry_test/CdpManager.Liquidation.t.sol +++ b/packages/contracts/foundry_test/CdpManager.Liquidation.t.sol @@ -114,6 +114,9 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { deal(address(eBTCToken), users[0], _cdpState.debt); // sugardaddy liquidator uint _debtLiquidatorBefore = eBTCToken.balanceOf(users[0]); uint _debtSystemBefore = cdpManager.getEntireSystemDebt(); + + _waitUntilRMColldown(); + vm.prank(users[0]); cdpManager.liquidate(cdpId1); uint _debtLiquidatorAfter = eBTCToken.balanceOf(users[0]); @@ -211,7 +214,9 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { uint _debtLiquidatorBefore = eBTCToken.balanceOf(users[0]); uint _debtSystemBefore = cdpManager.getEntireSystemDebt(); uint _collSystemBefore = cdpManager.getEntireSystemColl(); + _waitUntilRMColldown(); vm.prank(users[0]); + cdpManager.partiallyLiquidate(cdpId1, _partialLiq._repaidDebt, cdpId1, cdpId1); uint _debtLiquidatorAfter = eBTCToken.balanceOf(users[0]); uint _debtSystemAfter = cdpManager.getEntireSystemDebt(); @@ -265,6 +270,8 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { deal(address(eBTCToken), _liquidator, _debtSystemBefore); // sugardaddy liquidator uint _debtLiquidatorBefore = eBTCToken.balanceOf(_liquidator); + _waitUntilRMColldown(); + vm.prank(_liquidator); if (_n > 0) { cdpManager.liquidateCdps(_n); @@ -419,7 +426,7 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { // prepare sequence liquidation address _liquidator = users[users.length - 1]; deal(address(eBTCToken), _liquidator, cdpManager.getEntireSystemDebt()); // sugardaddy liquidator - // FIXME _waitUntilRMColldown(); + _waitUntilRMColldown(); uint _liquidatorBalBefore = collateral.balanceOf(_liquidator); uint _expectedReward = cdpManager.getCdpColl(cdpIds[0]) + @@ -618,7 +625,7 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { // prepare liquidation address _liquidator = users[users.length - 1]; deal(address(eBTCToken), _liquidator, cdpManager.getCdpDebt(userCdpid)); // sugardaddy liquidator - // FIXME _waitUntilRMColldown(); + _waitUntilRMColldown(); uint _liquidatorBalBefore = collateral.balanceOf(_liquidator); uint _expectedReward = ((cdpManager.getCdpDebt(userCdpid) * cdpManager.MCR()) / _newPrice) + diff --git a/packages/contracts/foundry_test/CdpManager.StakingSplitFee.t.sol b/packages/contracts/foundry_test/CdpManager.StakingSplitFee.t.sol index 7e85e31a2..8b3ee0faf 100644 --- a/packages/contracts/foundry_test/CdpManager.StakingSplitFee.t.sol +++ b/packages/contracts/foundry_test/CdpManager.StakingSplitFee.t.sol @@ -115,7 +115,7 @@ contract CdpManagerLiquidationTest is eBTCBaseInvariants { uint _feeBalBefore = collateral.balanceOf(splitFeeRecipient); uint _feeInternalAccountingBefore = activePool.getFeeRecipientClaimableColl(); - cdpManager.applyPendingGlobalState(); + cdpManager.syncPendingGlobalState(); uint _totalCollAfter = cdpManager.getEntireSystemColl(); uint _collateralTokensInActivePoolAfter = collateral.balanceOf(address(activePool)); diff --git a/packages/contracts/foundry_test/GracePeriod.Sync.t.sol b/packages/contracts/foundry_test/GracePeriod.Sync.t.sol new file mode 100644 index 000000000..aa0467264 --- /dev/null +++ b/packages/contracts/foundry_test/GracePeriod.Sync.t.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import "../contracts/Dependencies/LiquityMath.sol"; +import {eBTCBaseFixture} from "./BaseFixture.sol"; + +/* + Tests around GracePeriod + */ +contract GracePeriodBaseTests is eBTCBaseFixture { + event TCRNotified(uint TCR); /// NOTE: Mostly for debugging to ensure synch + + function setUp() public override { + eBTCBaseFixture.setUp(); + eBTCBaseFixture.connectCoreContracts(); + eBTCBaseFixture.connectLQTYContractsToCore(); + } + + address liquidator; + address safeUser; + address degen; + address risky; + + function testBasicSynchOnEachOperation() public { + uint256 price = priceFeedMock.fetchPrice(); + + // SKIPPED CAUSE BORING AF + // == Open CDP == // + console2.log("Open"); + uint256 openSnap = vm.snapshot(); + + { + _openSafeCdp(); + uint256 EXPECTED_OPEN_TCR = cdpManager.getTCR(price); + vm.revertTo(openSnap); + + // NOTE: Ported the same code of open because foundry doesn't find the event + address payable[] memory users; + users = _utils.createUsers(1); + safeUser = users[0]; + + uint256 debt1 = 1000e18; + uint256 coll1 = _utils.calculateCollAmount(debt1, price, 1.30e18); // Comfy unliquidatable + dealCollateral(safeUser, coll1); + vm.startPrank(safeUser); + collateral.approve(address(borrowerOperations), type(uint256).max); + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_OPEN_TCR); + bytes32 safeId = borrowerOperations.openCdp(debt1, bytes32(0), bytes32(0), coll1); + vm.stopPrank(); + + // == Adjust CDP == // + console2.log("Adjust"); + + dealCollateral(safeUser, 12345); + uint256 adjustSnap = vm.snapshot(); + + vm.startPrank(safeUser); + borrowerOperations.addColl(safeId, ZERO_ID, ZERO_ID, 123); + uint256 EXPECTED_ADJUST_TCR = cdpManager.getTCR(price); + vm.revertTo(adjustSnap); + + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_ADJUST_TCR); + borrowerOperations.addColl(safeId, ZERO_ID, ZERO_ID, 123); + vm.stopPrank(); + vm.revertTo(adjustSnap); + } + + // == Close CDP == // + { + console2.log("Close"); + uint256 closeSnapshot = vm.snapshot(); + // Open another so we can close it + bytes32 safeIdSecond = _openSafeCdp(); + + vm.startPrank(safeUser); + borrowerOperations.closeCdp(safeIdSecond); + uint256 EXPECTED_CLOSE_TCR = cdpManager.getTCR(price); + vm.revertTo(closeSnapshot); + vm.stopPrank(); + + // Open another so we can close it + safeIdSecond = _openSafeCdp(); + + vm.startPrank(safeUser); + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_CLOSE_TCR); + borrowerOperations.closeCdp(safeIdSecond); + vm.stopPrank(); + } + + // Revert back to here + vm.revertTo(openSnap); + + // Do the rest (Redemptions and liquidations) + _openSafeCdp(); + + bytes32[] memory cdps = _openRiskyCdps(1); + + // == Redemptions == // + // Get TCR after Redeem + // Snapshot back + // Then expect it to work + + uint256 biggerSnap = vm.snapshot(); + vm.startPrank(safeUser); + _partialRedemption(1e17, price); + // Get TCR here + uint256 EXPECTED_REDEMPTION_TCR = cdpManager.getTCR(price); + vm.revertTo(biggerSnap); + + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_REDEMPTION_TCR); + _partialRedemption(1e17, price); + + // Trigger Liquidations via Split (so price is constant) + _triggerRMViaSplit(); + cdpManager.syncGracePeriod(); + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + + // Liquidate 4x + vm.startPrank(safeUser); + uint256 liquidationSnapshotId = vm.snapshot(); // New snap for liquidations + + // == Liquidation 1 == // + { + console.log("Liq 1"); + + // Try liquidating a cdp + cdpManager.liquidate(cdps[0]); + // Get TCR after Liquidation + uint256 EXPECTED_TCR_FIRST_LIQ_TCR = cdpManager.getTCR(price); + // Revert so we can verify Event + vm.revertTo(liquidationSnapshotId); + + // Verify it worked + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_TCR_FIRST_LIQ_TCR); + cdpManager.liquidate(cdps[0]); + } + + // == Liquidate 2 == // + { + console.log("Liq 2"); + + // Re-revert for next Op + vm.revertTo(liquidationSnapshotId); + + // Try liquidating a cdp partially + cdpManager.partiallyLiquidate(cdps[0], 1e18, cdps[0], cdps[0]); + uint256 EXPECTED_TCR_SECOND_LIQ_TCR = cdpManager.getTCR(price); + vm.revertTo(liquidationSnapshotId); + + // Verify it worked + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_TCR_SECOND_LIQ_TCR); + cdpManager.partiallyLiquidate(cdps[0], 1e18, cdps[0], cdps[0]); + } + + // == Liquidate 3 == // + { + console.log("Liq 3"); + + // Re-revert for next Op + vm.revertTo(liquidationSnapshotId); + + // Try liquidating a cdp via the list (1) + cdpManager.liquidateCdps(1); + uint256 EXPECTED_TCR_THIRD_LIQ_TCR = cdpManager.getTCR(price); + vm.revertTo(liquidationSnapshotId); + + // Verify it worked + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_TCR_THIRD_LIQ_TCR); + cdpManager.liquidateCdps(1); + } + + // == Liquidate 4 == // + { + console.log("Liq 4"); + + // Re-revert for next Op + vm.revertTo(liquidationSnapshotId); + + // Try liquidating a cdp via the list (2) + bytes32[] memory cdpsToLiquidateBatch = new bytes32[](1); + cdpsToLiquidateBatch[0] = cdps[0]; + cdpManager.batchLiquidateCdps(cdpsToLiquidateBatch); + uint256 EXPECTED_TCR_FOURTH_LIQ_TCR = cdpManager.getTCR(price); + vm.revertTo(liquidationSnapshotId); + + vm.expectEmit(false, false, false, true); + emit TCRNotified(EXPECTED_TCR_FOURTH_LIQ_TCR); + cdpManager.batchLiquidateCdps(cdpsToLiquidateBatch); + vm.revertTo(liquidationSnapshotId); + } + + vm.stopPrank(); + } + + function _partialRedemption(uint256 toRedeem, uint256 price) internal { + //redemption + ( + bytes32 firstRedemptionHint, + uint partialRedemptionHintNICR, + uint truncatedEBTCamount, + uint partialRedemptionNewColl + ) = hintHelpers.getRedemptionHints(toRedeem, price, 0); + cdpManager.redeemCollateral( + toRedeem, + firstRedemptionHint, + ZERO_ID, + ZERO_ID, + partialRedemptionHintNICR, + 0, + 1e18 + ); + } + + function _openSafeCdp() internal returns (bytes32) { + address payable[] memory users; + users = _utils.createUsers(1); + safeUser = users[0]; + + // Deposit a big CDP, not at risk + uint256 _curPrice = priceFeedMock.getPrice(); + uint256 debt1 = 1000e18; + uint256 coll1 = _utils.calculateCollAmount(debt1, _curPrice, 1.30e18); // Comfy unliquidatable + + return _openTestCDP(safeUser, coll1, debt1); + } + + function _openRiskyCdps(uint256 numberOfCdpsAtRisk) internal returns (bytes32[] memory) { + address payable[] memory users; + users = _utils.createUsers(1); + + uint256 _curPrice = priceFeedMock.getPrice(); + + bytes32[] memory cdps = new bytes32[](numberOfCdpsAtRisk); + + // At risk CDPs (small CDPs) + for (uint256 i; i < numberOfCdpsAtRisk; i++) { + uint256 debt2 = 2e18; + uint256 coll2 = _utils.calculateCollAmount(debt2, _curPrice, 1.15e18); // Fairly risky + cdps[i] = _openTestCDP(users[0], coll2, debt2); + } + + uint TCR = cdpManager.getTCR(_curPrice); + assertGt(TCR, CCR); + + // Move past bootstrap phase to allow redemptions + vm.warp(cdpManager.getDeploymentStartTime() + cdpManager.BOOTSTRAP_PERIOD()); + + return cdps; + } + + function _triggerRMViaSplit() internal { + // 4% Downward Price will trigger RM but not Liquidations + collateral.setEthPerShare((collateral.getSharesByPooledEth(1e18) * 96) / 100); // 4% downturn, 5% should be enough to liquidate in-spite of RM + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + } +} diff --git a/packages/contracts/foundry_test/GracePeriod.t.sol b/packages/contracts/foundry_test/GracePeriod.t.sol new file mode 100644 index 000000000..041790fd6 --- /dev/null +++ b/packages/contracts/foundry_test/GracePeriod.t.sol @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.17; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import "../contracts/Dependencies/LiquityMath.sol"; +import {eBTCBaseFixture} from "./BaseFixture.sol"; + +/* + Tests around GracePeriod + */ +contract GracePeriodBaseTests is eBTCBaseFixture { + function setUp() public override { + eBTCBaseFixture.setUp(); + eBTCBaseFixture.connectCoreContracts(); + eBTCBaseFixture.connectLQTYContractsToCore(); + } + + address liquidator; + + // == DELAY TEST == // + // Delay of 15 minutes is enforced to liquidate CDPs in RM that are not below MCR - DONE + // No Delay for the Portion of CDPs which is below MCR - DONE + + // RM Triggered via Price - DONE + // RM Triggered via Split - DONE + + // RM Triggered via User Operation + // All operations where the system is in RM should trigger the countdown + + // RM untriggered via Price - DONE + // RM untriggered via Split - DONE + + // RM untriggered via User Operations - DONE + // All operations where the system goes off of RM should cancel the countdown + + /** + @dev Setup function to ensure we liquidate the correct amount of CDPs + + Current use: + - 1 healthy Cdps at 130% (1000 scale) + - 5 unhealthy Cdps at 115% (2 scale each) + + TCR somewhat above RM thresold ~130% + + - price drop via market price or rebase + + TCR just below RM threshold ~124.5% + + */ + function _openCdps(uint256 numberOfCdpsAtRisk) internal returns (bytes32[] memory) { + address payable[] memory users; + users = _utils.createUsers(2); + + bytes32[] memory cdps = new bytes32[](numberOfCdpsAtRisk + 1); + + // Deposit a big CDP, not at risk + uint256 _curPrice = priceFeedMock.getPrice(); + uint256 debt1 = 1000e18; + uint256 coll1 = _utils.calculateCollAmount(debt1, _curPrice, 1.30e18); // Comfy unliquidatable + + cdps[0] = _openTestCDP(users[0], coll1, debt1); + liquidator = users[0]; + + // At risk CDPs (small CDPs) + for (uint256 i; i < numberOfCdpsAtRisk; i++) { + uint256 debt2 = 2e18; + uint256 coll2 = _utils.calculateCollAmount(debt2, _curPrice, 1.15e18); // Fairly risky + cdps[1 + i] = _openTestCDP(users[1], coll2, debt2); + } + + uint TCR = cdpManager.getTCR(_curPrice); + assertGt(TCR, CCR); + + // Move past bootstrap phase to allow redemptions + vm.warp(cdpManager.getDeploymentStartTime() + cdpManager.BOOTSTRAP_PERIOD()); + + return cdps; + } + + function _openDegen() internal returns (bytes32) { + address payable[] memory users; + users = _utils.createUsers(1); + + uint256 _curPrice = priceFeedMock.getPrice(); + uint256 debt2 = 2e18; + uint256 coll2 = _utils.calculateCollAmount(debt2, _curPrice, 1.105e18); // Extremely Risky + bytes32 cdp = _openTestCDP(users[0], coll2, debt2); + + // Move past bootstrap phase to allow redemptions + vm.warp(cdpManager.getDeploymentStartTime() + cdpManager.BOOTSTRAP_PERIOD()); + + return cdp; + } + + /// @dev Verifies that the Grace Period Works when triggered by a Price Dump + function testTheBasicGracePeriodViaPrice() public { + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _triggerRMViaPrice(); + + _checkLiquidationsFSMForRmCdps(cdps); + } + + function testTheBasicGracePeriodViaPriceWithDegenGettingLiquidated() public { + // Open Safe and RM Risky + bytes32[] memory rmLiquidatableCdps = _openCdps(5); + + // Open Degen + bytes32 degen = _openDegen(); + + // Trigger RM + _triggerRMViaPrice(); + + uint256 degenSnapshot = vm.snapshot(); + // Do extra checks for Degen getting liquidated Etc.. + _checkLiquidationsForDegen(degen); + vm.revertTo(degenSnapshot); + + vm.startPrank(liquidator); + // Liquidate Degen + cdpManager.liquidate(degen); // Liquidate them "for real" + vm.stopPrank(); + + // Then do the same checks for Grace Period + _checkLiquidationsFSMForRmCdps(rmLiquidatableCdps); // Verify rest of behaviour is consistent with Grace Period + } + + function _triggerRMViaPrice() internal { + // 4% Downward Price will trigger RM but not Liquidations + priceFeedMock.setPrice((priceFeedMock.getPrice() * 96) / 100); // 4% downturn, 5% should be enough to liquidate in-spite of RM + uint256 reducedPrice = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(reducedPrice); + assertLt(TCR, 1.25e18, "!RM"); + } + + /// @dev Verifies that the Grace Period Works when triggered by a Slashing + function testTheBasicGracePeriodViaSplit() public { + bytes32[] memory cdps = _setupAndTriggerRMViaSplit(); + + _checkLiquidationsFSMForRmCdps(cdps); + } + + function _triggerRMViaSplit() internal { + // 4% Downward Price will trigger RM but not Liquidations + collateral.setEthPerShare((collateral.getSharesByPooledEth(1e18) * 96) / 100); // 4% downturn, 5% should be enough to liquidate in-spite of RM + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + } + + function _setupAndTriggerRMViaSplit() internal returns (bytes32[] memory) { + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _triggerRMViaSplit(); + + return cdps; + } + + function testTheBasicGracePeriodViaSplitWithDegenGettingLiquidated() public { + // Open Safe and RM Risky + bytes32[] memory rmLiquidatableCdps = _openCdps(5); + + // Open Degen + bytes32 degen = _openDegen(); + + // Trigger RM + _triggerRMViaSplit(); + + uint256 degenSnapshot = vm.snapshot(); + // Do extra checks for Degen getting liquidated Etc.. + _checkLiquidationsForDegen(degen); + vm.revertTo(degenSnapshot); + + vm.startPrank(liquidator); + // Liquidate Degen + cdpManager.liquidate(degen); // Liquidate them "for real" + vm.stopPrank(); + + // Then do the same checks for Grace Period + _checkLiquidationsFSMForRmCdps(rmLiquidatableCdps); // Verify rest of behaviour is consistent with Grace Period + } + + /// Verify that if the Grace Period is not started, true liquidations still happen + /// Verify that if the Grace Period is started, true liquidations still happen + /// Verify that if the Grace Period is finished, true liquidations still happen + + /// Verify Grace Period Synching applies to all external functions + + /// Claim Fee Split prob doesn't + + /// @dev Verifies liquidations wrt Grace Period and Cdps that can be always be liquidated + function _checkLiquidationsForDegen(bytes32 cdp) internal { + // Grace Period not started, expect reverts on liquidations + _assertSuccessOnAllLiquidationsDegen(cdp); + + cdpManager.syncGracePeriod(); + // 15 mins not elapsed, prove these cdps still revert + _assertSuccessOnAllLiquidationsDegen(cdp); + + // Grace Period Ended, liquidations work + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + _assertSuccessOnAllLiquidationsDegen(cdp); + } + + /// @dev Verifies liquidations wrt Grace Period and Cdps that can be liquidated only during RM + function _checkLiquidationsFSMForRmCdps(bytes32[] memory cdps) internal { + // Grace Period not started, expect reverts on liquidations + _assertRevertOnAllLiquidations(cdps); + + cdpManager.syncGracePeriod(); + // 15 mins not elapsed, prove these cdps still revert + _assertRevertOnAllLiquidations(cdps); + + // Grace Period Ended, liquidations work + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + _assertAllLiquidationSuccess(cdps); + } + + /** + @dev Test ways the grace period could be set in RM by expected exteral calls + @dev "Valid" actions are actions that can trigger grace period and also keep the system in recovery mode + + PriceDecreaseAction: + - setPrice + - setEthPerShare + + Action: + - openCdp + - adjustCdp + - redemptions + */ + function test_GracePeriodViaValidAction(uint8 priceDecreaseAction, uint8 action) public { + // vm.assume(priceDecreaseAction <= 1); + // vm.assume(action <= 3); + priceDecreaseAction = priceDecreaseAction % 2; + action = action % 4; + + // setup: create Cdps, enter RM via price change or rebase + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _execPriceDecreaseAction(priceDecreaseAction); + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + + // Pre valid + _assertRevertOnAllLiquidations(cdps); + + _execValidRMAction(cdps, action); + + _postValidActionLiquidationChecks(cdps); + } + + /// @dev Enumerate variants of ways the grace period could be reset + /// @dev "Valid" actions are actions that can trigger grace period and also keep the system in recovery mode + function test_GracePeriodResetWhenRecoveryModeExitedViaAction_WithoutGracePeriodSet( + uint8 priceDecreaseAction, + uint8 action + ) public { + // setup: create Cdps, enter RM via price change or rebase + // vm.assume(priceDecreaseAction <= 1); + // vm.assume(action <= 3); + priceDecreaseAction = priceDecreaseAction % 2; + action = action % 4; + + // setup: create Cdps, enter RM via price change or rebase + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _execPriceDecreaseAction(priceDecreaseAction); + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + + _assertRevertOnAllLiquidations(cdps); + + _execExitRMAction(cdps, action); + + _postExitRMLiquidationChecks(cdps); + } + + /// @dev Recovery mode is "virtually" entered and subsequently exited via price movement + /// @dev When no action is taken during RM, actions after RM naturally exited via price should behave as expected from NM + function test_GracePeriodResetWhenRecoveryModeExited_WithoutAction_WithoutGracePeriodSet( + uint8 priceDecreaseAction, + uint8 priceIncreaseAction + ) public { + // setup: create Cdps, enter RM via price change or rebase + // vm.assume(priceDecreaseAction <= 1); + // vm.assume(priceIncreaseAction <= 1); + priceDecreaseAction = priceDecreaseAction % 2; + priceIncreaseAction = priceIncreaseAction % 2; + + // setup: create Cdps, enter RM via price change or rebase + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _execPriceDecreaseAction(priceDecreaseAction); + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + + _assertRevertOnAllLiquidations(cdps); + + _execPriceIncreaseAction(priceIncreaseAction); + + // Confirm no longer in RM + TCR = cdpManager.getTCR(priceFeedMock.getPrice()); + assertGt(TCR, 1.25e18, "still in RM"); + + _assertRevertOnAllLiquidations(cdps); + } + + function test_GracePeriodResetWhenRecoveryModeExitedViaAction_WithGracePeriodSet( + uint8 priceDecreaseAction, + uint8 action + ) public { + // setup: create Cdps, enter RM via price change or rebase + // vm.assume(priceDecreaseAction <= 1); + // vm.assume(action <= 3); + priceDecreaseAction = priceDecreaseAction % 2; + action = action % 2; + + // setup: create Cdps, enter RM via price change or rebase + bytes32[] memory cdps = _openCdps(5); + assertTrue(cdps.length == 5 + 1, "length"); // 5 created, 1 safe (first) + + _execPriceDecreaseAction(priceDecreaseAction); + uint256 price = priceFeedMock.getPrice(); + + // Check if we are in RM + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + + // Set grace period before action which exits RM + cdpManager.syncGracePeriod(); + + _assertRevertOnAllLiquidations(cdps); + + _execExitRMAction(cdps, action); + + _postExitRMLiquidationChecks(cdps); + } + + function _execValidRMAction(bytes32[] memory cdps, uint256 action) internal { + address borrower = sortedCdps.getOwnerAddress(cdps[0]); + uint256 price = priceFeedMock.fetchPrice(); + if (action == 0) { + // openCdp + uint256 debt = 2e18; + uint256 coll = _utils.calculateCollAmount(debt, price, 1.3 ether); + + dealCollateral(borrower, coll); + + vm.prank(borrower); + borrowerOperations.openCdp(debt, ZERO_ID, ZERO_ID, coll); + } else if (action == 1) { + // adjustCdp: addColl + dealCollateral(borrower, 1); + + vm.prank(borrower); + borrowerOperations.addColl(cdps[0], ZERO_ID, ZERO_ID, 1); + } else if (action == 2) { + //adjustCdp: repayEBTC + vm.prank(borrower); + borrowerOperations.repayEBTC(cdps[0], 1, ZERO_ID, ZERO_ID); + } else if (action == 3) { + uint toRedeem = 5e17; + //redemption + ( + bytes32 firstRedemptionHint, + uint partialRedemptionHintNICR, + uint truncatedEBTCamount, + uint partialRedemptionNewColl + ) = hintHelpers.getRedemptionHints(toRedeem, price, 0); + + vm.prank(borrower); + cdpManager.redeemCollateral( + toRedeem, + firstRedemptionHint, + ZERO_ID, + ZERO_ID, + partialRedemptionHintNICR, + 0, + 1e18 + ); + } + + uint256 TCR = cdpManager.getTCR(price); + assertLt(TCR, 1.25e18, "!RM"); + } + + function _execExitRMAction(bytes32[] memory cdps, uint256 action) internal { + address borrower = sortedCdps.getOwnerAddress(cdps[0]); + uint256 price = priceFeedMock.fetchPrice(); + + // Debt and coll values to push us out of RM + uint256 debt = 1e18; + uint256 coll = _utils.calculateCollAmount(debt, price, 1.3 ether) * 100000; + if (action == 0) { + // openCdp + dealCollateral(borrower, coll); + + vm.prank(borrower); + borrowerOperations.openCdp(debt, ZERO_ID, ZERO_ID, coll); + } else if (action == 1) { + // adjustCdp: addColl (increase coll) + dealCollateral(borrower, coll); + + vm.prank(borrower); + borrowerOperations.addColl(cdps[0], ZERO_ID, ZERO_ID, coll); + } else if (action == 2) { + //adjustCdp: withdrawEBTC (reduce debt) + debt = cdpManager.getCdpDebt(cdps[0]); + console.log(debt); + + vm.prank(borrower); + borrowerOperations.repayEBTC(cdps[0], debt - 1, ZERO_ID, ZERO_ID); + } else if (action == 3) { + //adjustCdp: adjustCdpWithColl (reduce debt + increase coll) + debt = cdpManager.getCdpDebt(cdps[0]); + dealCollateral(borrower, coll); + + vm.prank(borrower); + borrowerOperations.adjustCdpWithColl( + cdps[0], + 0, + debt - 1, + false, + ZERO_ID, + ZERO_ID, + coll + ); + } + + uint256 TCR = cdpManager.getTCR(price); + console.log(TCR); + console.log(1.25e18); + assertGt(TCR, 1.25e18, "!RM"); + } + + /// @dev Trigger recovery mode via a dependency action that decreases price + function _execPriceDecreaseAction(uint8 action) internal { + // 4% Downward Price will trigger RM + if (action == 0) { + priceFeedMock.setPrice((priceFeedMock.getPrice() * 96) / 100); // 4% downturn, 5% should be enough to liquidate in-spite of RM + } else { + collateral.setEthPerShare((collateral.getSharesByPooledEth(1e18) * 96) / 100); // 4% downturn, 5% should be enough to liquidate in-spite of RM + } + } + + /// @dev Trigger exit of recovery mode via a dependency action that decreases price + function _execPriceIncreaseAction(uint8 action) internal { + // Upward Price will leave RM + if (action == 0) { + priceFeedMock.setPrice((priceFeedMock.getPrice() * 105) / 100); // 5% appreciation, sufficient to exit RM after 4% drawdown + } else { + collateral.setEthPerShare((collateral.getSharesByPooledEth(1e18) * 105) / 100); // 5% appreciation, sufficient to exit RM after 4% drawdown + } + } + + /// @dev Run these checks immediately after action that sets grace period + function _postValidActionLiquidationChecks(bytes32[] memory cdps) internal { + // Grace period timestamp is now + uint recoveryModeSetTimestamp = block.timestamp; + assertEq( + cdpManager.lastGracePeriodStartTimestamp(), + block.timestamp, + "lastGracePeriodStartTimestamp set time" + ); + + // Liquidations still revert + _assertRevertOnAllLiquidations(cdps); + + // Grace Period Ended + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + + // Grace period timestamp hasn't changed + assertEq( + cdpManager.lastGracePeriodStartTimestamp(), + recoveryModeSetTimestamp, + "lastGracePeriodStartTimestamp set time" + ); + + // Liquidations work + _assertAllLiquidationSuccess(cdps); + } + + function _postExitRMLiquidationChecks(bytes32[] memory cdps) internal { + // Grace period timestamp is now + assertEq( + cdpManager.lastGracePeriodStartTimestamp(), + cdpManager.UNSET_TIMESTAMP(), + "lastGracePeriodStartTimestamp unset" + ); + + // Liquidations still revert + _assertRevertOnAllLiquidations(cdps); + + // Grace Period Ended + vm.warp(block.timestamp + cdpManager.recoveryModeGracePeriod() + 1); + + // Grace period timestamp hasn't changed + assertEq( + cdpManager.lastGracePeriodStartTimestamp(), + cdpManager.UNSET_TIMESTAMP(), + "lastGracePeriodStartTimestamp unset" + ); + + // Only liquidations valid under normal work + _assertRevertOnAllLiquidations(cdps); + } + + function _assertRevertOnAllLiquidations(bytes32[] memory cdps) internal { + // Try liquidating a cdp + vm.expectRevert(); + cdpManager.liquidate(cdps[1]); + + // Try liquidating a cdp partially + vm.expectRevert(); + cdpManager.partiallyLiquidate(cdps[1], 1e18, cdps[1], cdps[1]); + + // Try liquidating a cdp via the list (1) + vm.expectRevert(); + cdpManager.liquidateCdps(1); + + // Try liquidating a cdp via the list (2) + bytes32[] memory cdpsToLiquidateBatch = new bytes32[](1); + cdpsToLiquidateBatch[0] = cdps[1]; + vm.expectRevert(); + cdpManager.batchLiquidateCdps(cdpsToLiquidateBatch); + } + + function _assertSuccessOnAllLiquidationsDegen(bytes32 cdp) internal { + vm.startPrank(liquidator); + uint256 snapshotId = vm.snapshot(); + + // Try liquidating a cdp + cdpManager.liquidate(cdp); + vm.revertTo(snapshotId); + + // Try liquidating a cdp partially + cdpManager.partiallyLiquidate(cdp, 1e18, cdp, cdp); + vm.revertTo(snapshotId); + + // Try liquidating a cdp via the list (2) + bytes32[] memory cdpsToLiquidateBatch = new bytes32[](1); + cdpsToLiquidateBatch[0] = cdp; + cdpManager.batchLiquidateCdps(cdpsToLiquidateBatch); + vm.revertTo(snapshotId); + + // Try liquidating a cdp via the list (1) + cdpManager.liquidateCdps(1); + vm.revertTo(snapshotId); + + console2.log("About to batchLiquidateCdps", uint256(cdp)); + + console2.log("This log if batchLiquidateCdps didn't revert"); + + vm.stopPrank(); + } + + function _assertAllLiquidationSuccess(bytes32[] memory cdps) internal { + vm.startPrank(liquidator); + uint256 snapshotId = vm.snapshot(); + + // Try liquidating a cdp + cdpManager.liquidate(cdps[1]); + vm.revertTo(snapshotId); + + // Try liquidating a cdp partially + cdpManager.partiallyLiquidate(cdps[1], 1e18, cdps[1], cdps[1]); + vm.revertTo(snapshotId); + + // Try liquidating a cdp via the list (1) + cdpManager.liquidateCdps(1); + vm.revertTo(snapshotId); + + // Try liquidating a cdp via the list (2) + bytes32[] memory cdpsToLiquidateBatch = new bytes32[](1); + cdpsToLiquidateBatch[0] = cdps[1]; + cdpManager.batchLiquidateCdps(cdpsToLiquidateBatch); + vm.revertTo(snapshotId); + + vm.stopPrank(); + } +} diff --git a/packages/contracts/foundry_test/SandwhichSniper.t.sol b/packages/contracts/foundry_test/SandwhichSniper.t.sol index 411fe850d..feb025774 100644 --- a/packages/contracts/foundry_test/SandwhichSniper.t.sol +++ b/packages/contracts/foundry_test/SandwhichSniper.t.sol @@ -97,10 +97,10 @@ contract SandWhichSniperTest is eBTCBaseFixture { // We can now liquidate victim /** SANDWHICH 3 */ vm.startPrank(users[0]); + vm.expectRevert("CdpManager: Recovery Mode grace period not started"); cdpManager.liquidate(cdpIdVictim); uint256 tcrEnd = cdpManager.getTCR(_newPrice); console.log("tcrEnd liquidation", tcrEnd); - assertEq(cdpManager.getCdpStatus(cdpIdVictim), 3); //closedByLiquidation - //assertGt(tcrEnd, 1250000000000000000); + assertEq(cdpManager.getCdpStatus(cdpIdVictim), 1); //Still Open (And safe until end of Grace Period) } } diff --git a/packages/contracts/foundry_test/WhaleSniper.t.sol b/packages/contracts/foundry_test/WhaleSniper.t.sol index f34434f51..0fd72ed74 100644 --- a/packages/contracts/foundry_test/WhaleSniper.t.sol +++ b/packages/contracts/foundry_test/WhaleSniper.t.sol @@ -56,7 +56,7 @@ contract WhaleSniperPOCTest is eBTCBaseFixture { console.log("tcr b4", tcr); // And show that the TCR goes down once you claim - cdpManager.applyPendingGlobalState(); + cdpManager.syncPendingGlobalState(); uint256 tcrAfter = cdpManager.getTCR(_curPrice); console.log("tcrAfter", tcrAfter); @@ -135,7 +135,7 @@ contract WhaleSniperPOCTest is eBTCBaseFixture { // hack manipulation to sync global index in attacker's benefit uint _oldIdx = _newIndex - _requiredDeltaIdxTriggeRM - 1234567890; collateral.setEthPerShare(_oldIdx); - cdpManager.applyPendingGlobalState(); + cdpManager.syncPendingGlobalState(); console.log("_oldIndex:", cdpManager.stFPPSg()); assertEq(_oldIdx, cdpManager.stFPPSg()); assertLt(_oldIdx, _curIndex); @@ -156,7 +156,7 @@ contract WhaleSniperPOCTest is eBTCBaseFixture { } // Now we take the split - cdpManager.applyPendingGlobalState(); + cdpManager.syncPendingGlobalState(); uint256 tcrAfter = cdpManager.getTCR(_curPrice); console.log("tcrAfter claim", tcrAfter); @@ -213,7 +213,7 @@ contract WhaleSniperPOCTest is eBTCBaseFixture { console.log("tcrAfterOpen Attacker", cdpManager.getTCR(_curPrice)); // Now we take the split - cdpManager.applyPendingGlobalState(); + cdpManager.syncPendingGlobalState(); uint256 tcrAfter = cdpManager.getTCR(_curPrice); console.log("tcrAfter claim", tcrAfter); diff --git a/packages/contracts/foundry_test/utils/LogUtils.sol b/packages/contracts/foundry_test/utils/LogUtils.sol index c377c66e3..c05395479 100644 --- a/packages/contracts/foundry_test/utils/LogUtils.sol +++ b/packages/contracts/foundry_test/utils/LogUtils.sol @@ -4,6 +4,7 @@ import "./Strings.sol"; contract LogUtils { using Strings for uint256; + using Strings for bytes32; enum GlueType { None, @@ -47,4 +48,8 @@ contract LogUtils { return result; } + + function bytes32ToString(bytes32 _bytes32) public pure returns (string memory) { + return _bytes32.bytes32ToString(); + } } diff --git a/packages/contracts/foundry_test/utils/Strings.sol b/packages/contracts/foundry_test/utils/Strings.sol index a260c2647..63358625f 100644 --- a/packages/contracts/foundry_test/utils/Strings.sol +++ b/packages/contracts/foundry_test/utils/Strings.sol @@ -31,4 +31,16 @@ library Strings { } return string(buffer); } + + function bytes32ToString(bytes32 _bytes) public pure returns (string memory) { + bytes memory charset = "0123456789abcdef"; + bytes memory result = new bytes(64); // as each byte will be represented by 2 chars in hex + + for (uint256 i = 0; i < 32; i++) { + result[i * 2] = charset[uint8(_bytes[i] >> 4)]; + result[i * 2 + 1] = charset[uint8(_bytes[i] & 0x0F)]; + } + + return string(result); + } } diff --git a/packages/contracts/test/CdpManagerTest.js b/packages/contracts/test/CdpManagerTest.js index 19fd308dc..92c398040 100644 --- a/packages/contracts/test/CdpManagerTest.js +++ b/packages/contracts/test/CdpManagerTest.js @@ -1113,7 +1113,12 @@ contract('CdpManager', async accounts => { assert.isTrue(await th.checkRecoveryMode(contracts)) await priceFeed.setPrice(dec(2500, 13)) - await borrowerOperations.addColl(_eCdpId, _eCdpId, _eCdpId, dec(10, 'ether'), { from: E }) + await borrowerOperations.addColl(_eCdpId, _eCdpId, _eCdpId, dec(10, 'ether'), { from: E }) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); // Try to liquidate C again. await debtToken.transfer(owner, toBN((await debtToken.balanceOf(D)).toString()), {from: D}); diff --git a/packages/contracts/test/CdpManager_RecoveryModeTest.js b/packages/contracts/test/CdpManager_RecoveryModeTest.js index 8752fe3d9..81f904f77 100644 --- a/packages/contracts/test/CdpManager_RecoveryModeTest.js +++ b/packages/contracts/test/CdpManager_RecoveryModeTest.js @@ -2837,7 +2837,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* Liquidate cdps. Cdps are ordered by ICR, from low to high: A, B, C. With 253 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. @@ -2906,7 +2911,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) assert.isTrue(ICR_D.gt(mv._MCR) && ICR_D.lt(TCR)) - assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* Liquidate cdps. Cdps are ordered by ICR, from low to high: A, B, C, D, E. With 300 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. @@ -2969,7 +2979,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) assert.isTrue(ICR_D.gt(mv._MCR) && ICR_D.lt(TCR)) - assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* Liquidate cdps. Cdps are ordered by ICR, from low to high: A, B, C, D, E. With 301 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. @@ -3030,7 +3045,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) const entireSystemCollBefore = await cdpManager.getEntireSystemColl() - const entireSystemDebtBefore = await cdpManager.getEntireSystemDebt() + const entireSystemDebtBefore = await cdpManager.getEntireSystemDebt() + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* Liquidate cdps. Cdps are ordered by ICR, from low to high: A, B, C, D, E. With 253 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. @@ -3142,7 +3162,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C_Before.gt(mv._MCR) && ICR_C_Before.lt(TCR)) + assert.isTrue(ICR_C_Before.gt(mv._MCR) && ICR_C_Before.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* Liquidate cdps. Cdps are ordered by ICR, from low to high: A, B, C, D, E. With 253 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. @@ -3508,7 +3533,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const cdpsToLiquidate = [_aliceCdpId, _bobCdpId, _carolCdpId] await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); @@ -3555,7 +3585,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const cdpsToLiquidate = [_aliceCdpId, _bobCdpId, _carolCdpId] await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); @@ -3620,7 +3655,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) assert.isTrue(ICR_D.gt(mv._MCR) && ICR_D.lt(TCR)) - assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* With 300 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. That leaves 97 EBTC in the Pool that won’t be enough to absorb Carol, @@ -3683,7 +3723,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) assert.isTrue(ICR_D.gt(mv._MCR) && ICR_D.lt(TCR)) - assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + assert.isTrue(ICR_E.gt(mv._MCR) && ICR_E.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); /* With 301 in the SP, Alice (102 debt) and Bob (101 debt) should be entirely liquidated. That leaves 97 EBTC in the Pool that won’t be enough to absorb Carol, @@ -3744,7 +3789,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) const entireSystemCollBefore = await cdpManager.getEntireSystemColl() - const entireSystemDebtBefore = await cdpManager.getEntireSystemDebt() + const entireSystemDebtBefore = await cdpManager.getEntireSystemDebt() + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const cdpsToLiquidate = [_aliceCdpId, _bobCdpId, _carolCdpId] await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); @@ -3794,7 +3844,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + assert.isTrue(ICR_C.gt(mv._MCR) && ICR_C.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const cdpsToLiquidate = [_aliceCdpId, _bobCdpId, _carolCdpId] await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); @@ -3861,7 +3916,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { assert.isTrue(ICR_A.gt(mv._MCR) && ICR_A.lt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C_Before.gt(mv._MCR) && ICR_C_Before.lt(TCR)) + assert.isTrue(ICR_C_Before.gt(mv._MCR) && ICR_C_Before.lt(TCR)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const cdpsToLiquidate = [_aliceCdpId, _bobCdpId, _carolCdpId] await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); @@ -4133,7 +4193,12 @@ contract('CdpManager - in Recovery Mode', async accounts => { // B and E are still in range 110-TCR. // Attempt to liquidate B, G, H, I, E. // Expected liquidator to fully absorb B (92 EBTC + 10 virtual debt), - // but not E as there are not enough funds in liquidator + // but not E as there are not enough funds in liquidator + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); const dEbtBefore = (await cdpManager.Cdps(_eCdpId))[0] diff --git a/packages/contracts/test/CdpManager_RecoveryMode_Batch_Liqudation_Test.js b/packages/contracts/test/CdpManager_RecoveryMode_Batch_Liqudation_Test.js index 546d7cb5f..d1e2641a9 100644 --- a/packages/contracts/test/CdpManager_RecoveryMode_Batch_Liqudation_Test.js +++ b/packages/contracts/test/CdpManager_RecoveryMode_Batch_Liqudation_Test.js @@ -74,7 +74,13 @@ contract('CdpManager - in Recovery Mode - back to normal mode in 1 tx', async ac } it('First cdp only doesn’t get out of Recovery Mode', async () => { - await setup() + await setup() + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); + let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(bob)).toString()), {from: bob}); @@ -88,7 +94,12 @@ contract('CdpManager - in Recovery Mode - back to normal mode in 1 tx', async ac await setup() let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); let _bobCdpId = await sortedCdps.cdpOfOwnerByIndex(bob, 0); - let _carolCdpId = await sortedCdps.cdpOfOwnerByIndex(carol, 0); + let _carolCdpId = await sortedCdps.cdpOfOwnerByIndex(carol, 0); + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(bob)).toString()), {from: bob}); @@ -140,7 +151,12 @@ contract('CdpManager - in Recovery Mode - back to normal mode in 1 tx', async ac assert.isTrue(ICR_A.gt(TCR)) assert.isTrue(ICR_B.gt(mv._MCR) && ICR_B.lt(TCR)) - assert.isTrue(ICR_C.lt(mv._ICR100)) + assert.isTrue(ICR_C.lt(mv._ICR100)) + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(bob)).toString()), {from: bob}); @@ -198,7 +214,13 @@ contract('CdpManager - in Recovery Mode - back to normal mode in 1 tx', async ac } it('First cdp only doesn’t get out of Recovery Mode', async () => { - await setup() + await setup() + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); + await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(bob)).toString()), {from: bob}); const tx = await cdpManager.liquidateCdps(1) @@ -210,7 +232,12 @@ contract('CdpManager - in Recovery Mode - back to normal mode in 1 tx', async ac it('Two cdps over MCR are liquidated', async () => { await setup() let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); - let _bobCdpId = await sortedCdps.cdpOfOwnerByIndex(bob, 0); + let _bobCdpId = await sortedCdps.cdpOfOwnerByIndex(bob, 0); + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(alice)).toString()), {from: alice}); await debtToken.transfer(owner, toBN((await debtToken.balanceOf(bob)).toString()), {from: bob}); diff --git a/packages/contracts/test/CdpManager_RecoveryMode_Cooldown_Test.js b/packages/contracts/test/CdpManager_RecoveryMode_Cooldown_Test.js new file mode 100644 index 000000000..559d6261e --- /dev/null +++ b/packages/contracts/test/CdpManager_RecoveryMode_Cooldown_Test.js @@ -0,0 +1,224 @@ +const deploymentHelper = require("../utils/deploymentHelpers.js") +const { TestHelper: th, MoneyValues: mv } = require("../utils/testHelpers.js") +const { toBN, dec, ZERO_ADDRESS } = th + +const CdpManagerTester = artifacts.require("./CdpManagerTester") +const EBTCToken = artifacts.require("./EBTCToken.sol") +const GovernorTester = artifacts.require("./GovernorTester.sol"); + +const assertRevert = th.assertRevert + +contract('CdpManager - Cooldown switch with respect to Recovery Mode to ensure delay on all external liquidations (exclusively for CDPs that can be liquidated in RM)', async accounts => { + const [bountyAddress, lpRewardsAddress, multisig] = accounts.slice(accounts.length - 3, accounts.length) + const [ + owner, + alice, bob, carol, dennis, erin, freddy, greta, harry, ida, + whale, defaulter_1, defaulter_2, defaulter_3, defaulter_4, + A, B, C, D, E, F, G, H, I + ] = accounts; + + let contracts + let cdpManager + let priceFeed + let sortedCdps + let collSurplusPool; + let _MCR; + let _CCR; + let _coolDownWait; + let collToken; + let splitFeeRecipient; + let authority; + + const openCdp = async (params) => th.openCdp(contracts, params) + + beforeEach(async () => { + await deploymentHelper.setDeployGasPrice(1000000000) + contracts = await deploymentHelper.deployTesterContractsHardhat() + let LQTYContracts = {} + LQTYContracts.feeRecipient = contracts.feeRecipient; + + cdpManager = contracts.cdpManager + priceFeed = contracts.priceFeedTestnet + sortedCdps = contracts.sortedCdps + debtToken = contracts.ebtcToken; + activePool = contracts.activePool; + defaultPool = contracts.defaultPool; + feeSplit = await contracts.cdpManager.stakingRewardSplit(); + liq_stipend = await contracts.cdpManager.LIQUIDATOR_REWARD(); + minDebt = await contracts.borrowerOperations.MIN_NET_COLL(); + _MCR = await cdpManager.MCR(); + _CCR = await cdpManager.CCR(); + LICR = await cdpManager.LICR(); + _coolDownWait = await cdpManager.recoveryModeGracePeriod(); + borrowerOperations = contracts.borrowerOperations; + collSurplusPool = contracts.collSurplusPool; + collToken = contracts.collateral; + hintHelpers = contracts.hintHelpers; + authority = contracts.authority; + + await deploymentHelper.connectCoreContracts(contracts, LQTYContracts) + + splitFeeRecipient = await LQTYContracts.feeRecipient; + }) + + it("Happy case: 3 CDPs with 1st Safe and 2 other Unsafe and ensure you must wait cooldown for one of them", async() => { + + await openCdp({ ICR: toBN(dec(149, 16)), extraEBTCAmount: toBN(minDebt.toString()).mul(toBN("10")), extraParams: { from: alice } }) + await openCdp({ ICR: toBN(dec(139, 16)), extraParams: { from: bob } }) + await openCdp({ ICR: toBN(dec(129, 16)), extraParams: { from: carol } }) + + let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); + let _bobCdpId = await sortedCdps.cdpOfOwnerByIndex(bob, 0); + let _carolCdpId = await sortedCdps.cdpOfOwnerByIndex(carol, 0); + await debtToken.transfer(owner, (await debtToken.balanceOf(alice)), {from : alice}); + await debtToken.transfer(owner, (await debtToken.balanceOf(bob)), {from : bob}); + await debtToken.transfer(owner, (await debtToken.balanceOf(carol)), {from : carol}); + + // price drops to trigger RM + let _newPrice = dec(6000, 13); + await priceFeed.setPrice(_newPrice); + let _aliceICRBefore = await cdpManager.getCurrentICR(_aliceCdpId, _newPrice); + let _bobICRBefore = await cdpManager.getCurrentICR(_bobCdpId, _newPrice); + let _carolICRBefore = await cdpManager.getCurrentICR(_carolCdpId, _newPrice); + let _tcrBefore = await cdpManager.getTCR(_newPrice); + console.log('_aliceICRBefore=' + _aliceICRBefore + ', _bobICRBefore=' + _bobICRBefore + ', _carolICRBefore=' + _carolICRBefore + ', _tcrBefore=' + _tcrBefore); + assert.isTrue(toBN(_tcrBefore.toString()).lt(_CCR)); + assert.isTrue(toBN(_aliceICRBefore.toString()).gt(_tcrBefore)); + assert.isTrue(toBN(_bobICRBefore.toString()).lt(_tcrBefore)); + assert.isTrue(toBN(_bobICRBefore.toString()).gt(_MCR)); + assert.isTrue(toBN(_carolICRBefore.toString()).lt(_MCR)); + + // trigger RM cooldown + await cdpManager.syncGracePeriod(); + await assertRevert(cdpManager.liquidate(_bobCdpId, {from: owner}), "Grace period yet to finish"); + + // cooldown only apply those [> MCR & < TCR] + await cdpManager.liquidate(_carolCdpId, {from: owner}); + assert.isFalse((await sortedCdps.contains(_carolCdpId))); + + // pass the cooldown wait + await ethers.provider.send("evm_increaseTime", [_coolDownWait.toNumber() + 1]); + await ethers.provider.send("evm_mine"); + await cdpManager.liquidate(_bobCdpId, {from: owner}); + assert.isFalse((await sortedCdps.contains(_bobCdpId))); + }) + + it("openCDP() in RM: should notifyStartGracePeriod() if RM persist or notifyEndGracePeriod() if RM exit", async() => { + + await openCdp({ ICR: toBN(dec(149, 16)), extraEBTCAmount: toBN(minDebt.toString()).mul(toBN("10")), extraParams: { from: alice } }) + let _initVal = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_initVal.gt(toBN('0'))); + await openCdp({ ICR: toBN(dec(129, 16)), extraParams: { from: carol } }) + + // price drops to trigger RM + let _newPrice = dec(6000, 13); + await priceFeed.setPrice(_newPrice); + let _tcrBefore = await cdpManager.getTCR(_newPrice); + assert.isTrue(toBN(_tcrBefore.toString()).lt(_CCR)); + let _stillInitVal = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_stillInitVal.eq(_initVal)); + + // trigger RM cooldown by open a new CDP + await openCdp({ ICR: _CCR.add(toBN('1234567890123456789')), extraParams: { from: bob } }) + let _bobCdpId = await sortedCdps.cdpOfOwnerByIndex(bob, 0); + let _bobICRBefore = await cdpManager.getCurrentICR(_bobCdpId, _newPrice); + let _tcrInMiddle = await cdpManager.getTCR(_newPrice); + console.log('_tcrInMiddle=' + _tcrInMiddle); + assert.isTrue(toBN(_tcrInMiddle.toString()).lt(_CCR)); + let _rmTriggerTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmTriggerTimestamp.gt(toBN('0'))); + assert.isTrue(_rmTriggerTimestamp.lt(_initVal)); + + // trigger RM exit by open another new CDP + await openCdp({ ICR: toBN(dec(349, 16)), extraEBTCAmount: toBN(minDebt.toString()).mul(toBN("100")), extraParams: { from: owner } }) + let _tcrFinal = await cdpManager.getTCR(_newPrice); + console.log('_tcrFinal=' + _tcrFinal); + assert.isTrue(toBN(_tcrFinal.toString()).gt(_CCR)); + let _rmExitTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmExitTimestamp.eq(_initVal)); + }) + + it("closeCDP(): should always notifyEndGracePeriod() since TCR after close is aboce CCR", async() => { + + await openCdp({ ICR: toBN(dec(155, 16)), extraEBTCAmount: toBN(minDebt.toString()).mul(toBN("10")), extraParams: { from: alice } }) + let _initVal = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_initVal.gt(toBN('0'))); + await openCdp({ ICR: toBN(dec(126, 16)), extraParams: { from: carol } }) + + let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); + let _carolCdpId = await sortedCdps.cdpOfOwnerByIndex(carol, 0); + + // price drops to trigger RM & cooldown + let _originalPrice = await priceFeed.getPrice(); + let _newPrice = dec(5992, 13); + await priceFeed.setPrice(_newPrice); + let _tcrBefore = await cdpManager.getTCR(_newPrice); + let _aliceICRBefore = await cdpManager.getCurrentICR(_aliceCdpId, _newPrice); + console.log('_tcrBefore=' + _tcrBefore + ',_aliceICRBefore=' + _aliceICRBefore); + assert.isTrue(toBN(_tcrBefore.toString()).lt(_CCR)); + await cdpManager.syncGracePeriod(); + let _rmTriggerTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmTriggerTimestamp.gt(toBN('0'))); + assert.isTrue(_rmTriggerTimestamp.lt(_initVal)); + + // reset RM cooldown by close a CDP + await priceFeed.setPrice(_originalPrice); + await borrowerOperations.closeCdp(_carolCdpId, { from: carol } ); + assert.isFalse((await sortedCdps.contains(_carolCdpId))); + let _tcrAfter = await cdpManager.getTCR(_originalPrice); + assert.isTrue(toBN(_tcrAfter.toString()).gt(_CCR)); + let _rmExitTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmExitTimestamp.eq(_initVal)); + }) + + it("adjustCDP(): should notifyStartGracePeriod() if RM persist or notifyEndGracePeriod() if RM exit", async() => { + let _dustVal = 123456789; + await openCdp({ ICR: toBN(dec(155, 16)), extraEBTCAmount: toBN(minDebt.toString()).mul(toBN("10")), extraParams: { from: alice } }) + let _initVal = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_initVal.gt(toBN('0'))); + await openCdp({ ICR: toBN(dec(126, 16)), extraParams: { from: carol } }) + + let _aliceCdpId = await sortedCdps.cdpOfOwnerByIndex(alice, 0); + let _carolCdpId = await sortedCdps.cdpOfOwnerByIndex(carol, 0); + let _carolDebt = await cdpManager.getCdpDebt(_carolCdpId); + + // price drops to trigger RM & cooldown + let _originalPrice = await priceFeed.getPrice(); + let _newPrice = dec(5992, 13); + await priceFeed.setPrice(_newPrice); + let _tcrBefore = await cdpManager.getTCR(_newPrice); + let _aliceICRBefore = await cdpManager.getCurrentICR(_aliceCdpId, _newPrice); + assert.isTrue(toBN(_tcrBefore.toString()).lt(_CCR)); + await cdpManager.syncGracePeriod(); + let _rmTriggerTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmTriggerTimestamp.gt(toBN('0'))); + assert.isTrue(_rmTriggerTimestamp.lt(_initVal)); + + // reset RM cooldown by adjust a CDP in Normal Mode (withdraw more debt) + await priceFeed.setPrice(_originalPrice); + let _tcrAfter = await cdpManager.getTCR(_originalPrice); + assert.isTrue(toBN(_tcrAfter.toString()).gt(_CCR)); + await borrowerOperations.withdrawEBTC(_carolCdpId, _dustVal, _carolCdpId, _carolCdpId, { from: carol } ); + let _rmExitTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmExitTimestamp.eq(_initVal)); + + // price drops to trigger RM & cooldown again by adjust CDP (add more collateral) + await priceFeed.setPrice(_newPrice); + await collToken.deposit({from : carol, value: _dustVal}); + await borrowerOperations.addColl(_carolCdpId, _carolCdpId, _carolCdpId, _dustVal, { from: carol } ); + let _adjustRmTriggerTimestamp = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_adjustRmTriggerTimestamp.gt(_rmTriggerTimestamp)); + assert.isTrue(_adjustRmTriggerTimestamp.lt(_initVal)); + + // end RM cooldown by adjust a CDP in RM (repayment) + await debtToken.approve(borrowerOperations.address, _carolDebt, {from: carol}); + await borrowerOperations.repayEBTC(_carolCdpId, _carolDebt, _carolCdpId, _carolCdpId, { from: carol } ); + let _tcrFinal = await cdpManager.getTCR(_newPrice); + assert.isTrue(toBN(_tcrFinal.toString()).gt(_CCR)); + let _rmExitFinal = await cdpManager.lastGracePeriodStartTimestamp(); + assert.isTrue(_rmExitFinal.eq(_initVal)); + + }) + + +}) \ No newline at end of file diff --git a/packages/contracts/test/CdpManager_SimpleLiquidation_Test.js b/packages/contracts/test/CdpManager_SimpleLiquidation_Test.js index 5e7b42971..9ad08df77 100644 --- a/packages/contracts/test/CdpManager_SimpleLiquidation_Test.js +++ b/packages/contracts/test/CdpManager_SimpleLiquidation_Test.js @@ -392,7 +392,12 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco let aliceDebt = await cdpManager.getCdpDebt(aliceCdpId); let aliceColl = await cdpManager.getCdpColl(aliceCdpId); let prevDebtOfOwner = await debtToken.balanceOf(owner); - assert.isTrue(toBN(prevDebtOfOwner.toString()).gt(toBN(aliceDebt.toString()))); + assert.isTrue(toBN(prevDebtOfOwner.toString()).gt(toBN(aliceDebt.toString()))); + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); // liquidate alice in recovery mode let prevETHOfOwner = await ethers.provider.getBalance(owner); diff --git a/packages/contracts/test/CdpManager_StakingSplitFee_Test.js b/packages/contracts/test/CdpManager_StakingSplitFee_Test.js index e67aa71de..d856da079 100644 --- a/packages/contracts/test/CdpManager_StakingSplitFee_Test.js +++ b/packages/contracts/test/CdpManager_StakingSplitFee_Test.js @@ -110,7 +110,7 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco let _expectedFee = _fees[0].mul(_newIndex).div(mv._1e18BN); let _feeBalBefore = await activePool.getFeeRecipientClaimableColl(); - await cdpManager.applyPendingGlobalState(); + await cdpManager.syncPendingGlobalState(); let _feeBalAfter = await activePool.getFeeRecipientClaimableColl(); th.assertIsApproximatelyEqual(_feeBalAfter.sub(_feeBalBefore), _fees[0]); @@ -133,13 +133,13 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco assert.isTrue(toBN(_icrAfter.toString()).gt(toBN(_icrBefore.toString()))); assert.isTrue(toBN(_tcrAfter.toString()).gt(toBN(_tcrBefore.toString()))); - // ensure applyPendingGlobalState() could be called any time + // ensure syncPendingGlobalState() could be called any time let _loop = 10; for(let i = 0;i < _loop;i++){ _newIndex = _newIndex.add(_deltaIndex.div(toBN("10"))); await collToken.setEthPerShare(_newIndex); let _newBalClaimable = await activePool.getFeeRecipientClaimableColl(); - await cdpManager.applyPendingGlobalState(); + await cdpManager.syncPendingGlobalState(); assert.isTrue(_newBalClaimable.lt(await activePool.getFeeRecipientClaimableColl())); assert.isTrue(_newIndex.eq(await cdpManager.stFPPSg())); } @@ -199,7 +199,7 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco let _expectedFeeShare = _fees[0]; let _expectedFee = _expectedFeeShare.mul(_newIndex).div(mv._1e18BN); let _feeBalBefore = await activePool.getFeeRecipientClaimableColl(); - await cdpManager.applyPendingGlobalState(); + await cdpManager.syncPendingGlobalState(); let _feeBalAfter = await activePool.getFeeRecipientClaimableColl(); let _actualFee = _feeBalAfter.sub(_feeBalBefore); @@ -281,7 +281,12 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco let _partialDebtRepaid = minDebt; let _expectedSeizedColl = toBN(_partialDebtRepaid.toString()).mul(_MCR).div(toBN(_newPrice)); let _expectedLiquidatedColl = _expectedSeizedColl.mul(mv._1e18BN).div(_newIndex); - let _collBeforeLiquidator = await collToken.balanceOf(owner); + let _collBeforeLiquidator = await collToken.balanceOf(owner); + + // trigger cooldown and pass the liq wait + await cdpManager.syncGracePeriod(); + await ethers.provider.send("evm_increaseTime", [901]); + await ethers.provider.send("evm_mine"); await cdpManager.partiallyLiquidate(_aliceCdpId, _partialDebtRepaid, _aliceCdpId, _aliceCdpId, {from: owner}); let _collAfterLiquidator = await collToken.balanceOf(owner); @@ -332,7 +337,7 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco let _expectedFee = _expectedFeeShare.mul(_newIndex).div(mv._1e18BN); let _feeBalBefore = await activePool.getFeeRecipientClaimableColl(); - await cdpManager.applyPendingGlobalState(); + await cdpManager.syncPendingGlobalState(); let _feeBalAfter = await activePool.getFeeRecipientClaimableColl(); let _stFeePerUnitg = await cdpManager.stFeePerUnitg(); @@ -420,7 +425,7 @@ contract('CdpManager - Simple Liquidation with external liquidators', async acco await collToken.setEthPerShare(_newIndex); // claim fee - await cdpManager.applyPendingGlobalState(); + await cdpManager.syncPendingGlobalState(); // final check _cdpDebtColl = await cdpManager.getEntireDebtAndColl(_cdpId);