diff --git a/contracts/credit/CreditFacadeV3.sol b/contracts/credit/CreditFacadeV3.sol index 556708cf..4d627691 100644 --- a/contracts/credit/CreditFacadeV3.sol +++ b/contracts/credit/CreditFacadeV3.sol @@ -48,7 +48,7 @@ uint256 constant OPEN_CREDIT_ACCOUNT_FLAGS = ALL_PERMISSIONS & ~(DECREASE_DEBT_P uint256 constant CLOSE_CREDIT_ACCOUNT_FLAGS = ALL_PERMISSIONS & ~(INCREASE_DEBT_PERMISSION | WITHDRAW_PERMISSION); -uint256 constant LIQUIDATE_CREDIT_ACCOUNT_FLAGS = EXTERNAL_CALLS_PERMISSION; +uint256 constant LIQUIDATE_CREDIT_ACCOUNT_FLAGS = EXTERNAL_CALLS_PERMISSION | ADD_COLLATERAL_PERMISSION; /// @title Credit facade V3 /// @notice Provides a user interface to open, close and liquidate leveraged positions in the credit manager, @@ -289,27 +289,32 @@ contract CreditFacadeV3 is ICreditFacadeV3, ACLNonReentrantTrait { /// - Evaluates account's collateral and debt to determine whether liquidated account is unhealthy or expired /// - Cancels immature scheduled withdrawals and returns tokens to the account (on emergency, even mature /// withdrawals are returned) - /// - Performs a multicall (only adapter calls allowed) + /// - Performs a multicall (only `addCollateral` and adapter calls are allowed) /// - Erases all bots permissions - /// - Closes a credit account in the credit manager, distributing the funds between pool, owner and liquidator + /// - Liquidates a credit account in the credit manager by repaying debt to the pool, removing quotas + /// and transferring specified tokens to the liquidator /// - If pool incurs a loss on liquidation, further borrowing through the facade is forbidden /// - If cumulative loss from bad debt liquidations exceeds the threshold, the facade is paused /// @notice Typically, a liquidator would swap all holdings on the account to underlying via multicall and receive - /// the premium. An alternative strategy would be to allow credit manager to take underlying shortfall from - /// the caller and receive all account's holdings directly to handle them in another way. + /// the premium in underlying (in this case, one can set `tokensToTransferMask` to `1`). + /// An alternative strategy would be to add underlying collateral to repay debt and receive specified tokens + /// to handle them in another way. + /// @notice Accounts stays open after liqudiation with zero debt and leftover funds /// @param creditAccount Account to liquidate - /// @param to Address to send tokens left on the account after closure and funds distribution - /// @param skipTokensMask Bit mask of tokens that should be skipped + /// @param to Address to send tokens left on the account after liquidation + /// @param tokensToTransferMask Bit mask of tokens left on the account that should be sent /// @param convertToETH Whether to unwrap WETH before sending to `to` /// @param calls List of calls to perform before liquidating the account /// @dev When the credit facade is paused, reverts if caller is not an approved emergency liquidator /// @dev Reverts if `creditAccount` is not opened in connected credit manager /// @dev Reverts if account is not liquidatable /// @dev Reverts if account's debt was updated in the same block + /// @dev Reverts if total value of funds left on the account after repaying debt to the pool and sending specified + /// tokens to the liquidator is insufficient function liquidateCreditAccount( address creditAccount, address to, - uint256 skipTokensMask, + uint256 tokensToTransferMask, bool convertToETH, MultiCall[] calldata calls ) @@ -353,13 +358,10 @@ contract CreditFacadeV3 is ICreditFacadeV3, ACLNonReentrantTrait { collateralDebtData.enabledTokensMask = collateralDebtData.enabledTokensMask.enable(tokensToEnable); // U:[FA-15] } - // add replace: add underlying -> remove any enabled token [ with profit] - if (skipCalls < calls.length) { - FullCheckParams memory fullCheckParams = _multicall( + _multicall( creditAccount, calls, collateralDebtData.enabledTokensMask, LIQUIDATE_CREDIT_ACCOUNT_FLAGS, skipCalls ); // U:[FA-16] - collateralDebtData.enabledTokensMask = fullCheckParams.enabledTokensMaskAfter; // U:[FA-16] } _eraseAllBotPermissions({creditAccount: creditAccount}); // U:[FA-16] @@ -367,11 +369,10 @@ contract CreditFacadeV3 is ICreditFacadeV3, ACLNonReentrantTrait { (uint256 remainingFunds, uint256 reportedLoss) = ICreditManagerV3(creditManager).liquidateCreditAccount({ creditAccount: creditAccount, collateralDebtData: collateralDebtData, - payer: msg.sender, to: to, - skipTokensMask: skipTokensMask, + tokensToTransferMask: tokensToTransferMask, convertToETH: convertToETH, - isExpiredLiquidation: isExpired + isExpired: isExpired }); // U:[FA-16] if (reportedLoss > 0) { diff --git a/contracts/credit/CreditManagerV3.sol b/contracts/credit/CreditManagerV3.sol index aafc7d40..ae6ce5b5 100644 --- a/contracts/credit/CreditManagerV3.sol +++ b/contracts/credit/CreditManagerV3.sol @@ -194,11 +194,6 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT CreditAccountInfo storage newCreditAccountInfo = creditAccountInfo[creditAccount]; - // accounts are reusable, so debt and interest index must be reset either when opening an account or closing it - // to make potential liquidations cheaper, they are reset here - newCreditAccountInfo.debt = 0; // U:[CM-6] - newCreditAccountInfo.cumulativeIndexLastUpdate = _poolBaseInterestIndex(); // U:[CM-6] - // newCreditAccountInfo.flags = 0; // newCreditAccountInfo.lastDebtUpdate = 0; // newCreditAccountInfo.borrower = onBehalfOf; @@ -255,28 +250,26 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT creditAccountsSet.remove(creditAccount); // U:[CM-8] } - /// @notice Closes a credit account by repaying debt to the pool, removing quotas and returning account to the factory - /// @param creditAccount Account to close + /// @notice Liquidates a credit account + /// @param creditAccount Account to liquidate /// @param collateralDebtData A struct with account's debt and collateral data - /// @param payer Address to transfer underlying from in case account's balance is insufficient - /// @param to Address to transfer tokens that left on the account after closure and repayments - /// @param skipTokensMask Bit mask of tokens that should be skipped + /// @param to Address to transfer tokens left on the account after liquidation + /// @param tokensToTransferMask Bit mask of tokens left on the account that should be sent /// @param convertToETH If true and any of transferred tokens is WETH, it will be sent to withdrawal manager, - /// from which `to` can later claim it as ETH - /// @return remainingFunds Amount of underlying sent to account owner on liquidation + /// from which credit facade should claim it as ETH to `to` at the end of the call + /// @return remainingFunds Total value of assets left on the account after liquidation /// @return loss Loss incurred on liquidation /// @dev If `loss > 0`, zeroes out limits for account's quoted tokens in the quota keeper - /// @custom:expects `cdd` is a result of `calcDebtAndCollateral` in `DEBT_COLLATERAL_{x}_WITHDRAWALS` mode, where x is - /// `WITHOUT` for normal closure, `CANCEL` for liquidation and `FORCE_CANCEL` for emergency liquidation - /// @custom:invariant `remainingFunds * loss == 0` + /// @custom:expects Account is opened in this credit manager + /// @custom:expects `cdd` is a result of `calcDebtAndCollateral` in `DEBT_COLLATERAL_{x}_WITHDRAWALS` mode, + /// where x is `CANCEL` for normal liquidation and `FORCE_CANCEL` for emergency liquidation function liquidateCreditAccount( address creditAccount, CollateralDebtData calldata collateralDebtData, - address payer, address to, - uint256 skipTokensMask, + uint256 tokensToTransferMask, bool convertToETH, - bool isExpiredLiquidation + bool isExpired ) external override @@ -284,55 +277,35 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT creditFacadeOnly // U:[CM-2] returns (uint256 remainingFunds, uint256 loss) { - getBorrowerOrRevert(creditAccount); // U:[CM-7] + CreditAccountInfo storage currentCreditAccountInfo = creditAccountInfo[creditAccount]; + if (currentCreditAccountInfo.lastDebtUpdate == block.number) { + revert DebtUpdatedTwiceInOneBlockException(); + } - { - CreditAccountInfo storage currentCreditAccountInfo = creditAccountInfo[creditAccount]; - if (currentCreditAccountInfo.lastDebtUpdate == block.number) { - revert DebtUpdatedTwiceInOneBlockException(); // U:[CM-7] - } + currentCreditAccountInfo.debt = 0; + currentCreditAccountInfo.lastDebtUpdate = uint64(block.number); + + // currentCreditAccountInfo.cumulativeQuotaInterest = 1; + // currentCreditAccountInfo.quotaFees = 0; + assembly { + let slot := add(currentCreditAccountInfo.slot, 2) + sstore(slot, 1) } + + remainingFunds = IERC20(underlying).safeBalanceOf({account: creditAccount}); + uint256 minRemainingFunds; + { uint256 amountToPool; uint256 profit; - (amountToPool, remainingFunds, profit, loss) = collateralDebtData.calcLiquidationPayments({ - liquidationDiscount: isExpiredLiquidation ? liquidationDiscountExpired : liquidationDiscount, - feeLiquidation: isExpiredLiquidation ? feeLiquidationExpired : feeLiquidation, + (amountToPool, minRemainingFunds, profit, loss) = collateralDebtData.calcLiquidationPayments({ + liquidationDiscount: isExpired ? liquidationDiscountExpired : liquidationDiscount, + feeLiquidation: isExpired ? feeLiquidationExpired : feeLiquidation, amountWithFeeFn: _amountWithFee, amountMinusFeeFn: _amountMinusFee }); // U:[CM-8] - // Computes total value of tokens which will be kept on account - if (remainingFunds > 1) { - // Calc totalValue for skipTokensMask without underlying - skipTokensMask = skipTokensMask.disable(UNDERLYING_TOKEN_MASK); // U:[CM-8] - - if (skipTokensMask != 0) { - uint256 skipTokensValue = _getTokensValue(creditAccount, skipTokensMask); - unchecked { - remainingFunds = remainingFunds > skipTokensValue ? remainingFunds - skipTokensValue : 0; - } - } - } - - uint256 underlyingBalance = IERC20(underlying).safeBalanceOf({account: creditAccount}); // U:[CM-8] - { - uint256 distributedFunds = amountToPool + remainingFunds + 1; - - if (underlyingBalance < distributedFunds) { - unchecked { - IERC20(underlying).safeTransferFrom({ - from: payer, - to: creditAccount, - amount: _amountWithFee(distributedFunds - underlyingBalance) - }); // U:[CM-8] - } - - underlyingBalance = distributedFunds; - } - } - if (collateralDebtData.quotedTokens.length != 0) { bool setLimitsToZero = loss > 0; // U:[CM-8] @@ -346,35 +319,53 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT if (amountToPool != 0) { ICreditAccountBase(creditAccount).transfer({token: underlying, to: pool, amount: amountToPool}); // U:[CM-8] unchecked { - underlyingBalance -= amountToPool; + remainingFunds -= amountToPool; } } if (collateralDebtData.debt + profit + loss != 0) { _poolRepayCreditAccount(collateralDebtData.debt, profit, loss); // U:[CM-8] } + } - if (remainingFunds > 1) { - uint256 amountToLiquidator = underlyingBalance - remainingFunds; - if (amountToLiquidator != 0) { - _safeTokenTransfer({ - creditAccount: creditAccount, - token: underlying, - to: to, - amount: amountToLiquidator, - convertToETH: convertToETH - }); // U:[CM-8] + uint256 skipTokensValue; + uint256 skipTokensMask = + collateralDebtData.enabledTokensMask.disable(UNDERLYING_TOKEN_MASK).disable(tokensToTransferMask); + if (skipTokensMask != 0) skipTokensValue = _getTokensValue(creditAccount, skipTokensMask); + + if (remainingFunds + skipTokensValue < minRemainingFunds) { + revert InsufficientRemainingFundsException(); + } + + if (tokensToTransferMask & UNDERLYING_TOKEN_MASK != 0) { + tokensToTransferMask = tokensToTransferMask.disable(UNDERLYING_TOKEN_MASK); + + uint256 amountToLiquidator = remainingFunds; + if (skipTokensValue < minRemainingFunds) { + unchecked { + amountToLiquidator -= minRemainingFunds - skipTokensValue; } + } - skipTokensMask = skipTokensMask.enable(UNDERLYING_TOKEN_MASK); // U:[CM-8] + if (amountToLiquidator != 0) { + _safeTokenTransfer({ + creditAccount: creditAccount, + token: underlying, + to: to, + amount: amountToLiquidator, + convertToETH: convertToETH + }); // U:[CM-8] + remainingFunds -= amountToLiquidator; } } + remainingFunds += skipTokensValue; + _batchTokensTransfer({ creditAccount: creditAccount, to: to, convertToETH: convertToETH, - tokensToTransferMask: collateralDebtData.enabledTokensMask.disable(skipTokensMask) + tokensToTransferMask: tokensToTransferMask }); // U:[CM-8, 9] } @@ -700,7 +691,7 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT cdd.debt = currentCreditAccountInfo.debt; // U:[CM-20] cdd.cumulativeIndexLastUpdate = currentCreditAccountInfo.cumulativeIndexLastUpdate; // U:[CM-20] - cdd.cumulativeIndexNow = _poolBaseInterestIndex(); // U:[CM-20] + cdd.cumulativeIndexNow = IPoolV3(pool).baseInterestIndex(); // U:[CM-20] if (task == CollateralCalcTask.GENERIC_PARAMS) { return cdd; // U:[CM-20] @@ -1480,11 +1471,6 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT return amount; } - /// @dev Internal wrapper for `pool.baseInterestIndex` call to reduce contract size - function _poolBaseInterestIndex() internal view returns (uint256) { - return IPoolV3(pool).baseInterestIndex(); - } - /// @dev Internal wrapper for `pool.repayCreditAccount` call to reduce contract size function _poolRepayCreditAccount(uint256 debt, uint256 profit, uint256 loss) internal { IPoolV3(pool).repayCreditAccount(debt, profit, loss); diff --git a/contracts/interfaces/ICreditFacadeV3.sol b/contracts/interfaces/ICreditFacadeV3.sol index bf5ead8a..a561a01e 100644 --- a/contracts/interfaces/ICreditFacadeV3.sol +++ b/contracts/interfaces/ICreditFacadeV3.sol @@ -126,7 +126,7 @@ interface ICreditFacadeV3 is IVersion, ICreditFacadeV3Events { function liquidateCreditAccount( address creditAccount, address to, - uint256 skipTokensMask, + uint256 tokensToTransferMask, bool convertToETH, MultiCall[] calldata calls ) external; diff --git a/contracts/interfaces/ICreditFacadeV3Multicall.sol b/contracts/interfaces/ICreditFacadeV3Multicall.sol index 69d5599e..ac10827c 100644 --- a/contracts/interfaces/ICreditFacadeV3Multicall.sol +++ b/contracts/interfaces/ICreditFacadeV3Multicall.sol @@ -67,6 +67,7 @@ interface ICreditFacadeV3Multicall { /// @param token Token to add /// @param amount Amount to add /// @dev Requires token approval from caller to the credit manager + /// @dev This method can also be called during liquidation function addCollateral(address token, uint256 amount) external; /// @notice Adds collateral to account using signed EIP-2612 permit message @@ -74,6 +75,7 @@ interface ICreditFacadeV3Multicall { /// @param amount Amount to add /// @param deadline Permit deadline /// @dev `v`, `r`, `s` must be a valid signature of the permit message from caller to the credit manager + /// @dev This method can also be called during liquidation function addCollateralWithPermit(address token, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; diff --git a/contracts/interfaces/ICreditManagerV3.sol b/contracts/interfaces/ICreditManagerV3.sol index 6ee340ad..094a9d9b 100644 --- a/contracts/interfaces/ICreditManagerV3.sol +++ b/contracts/interfaces/ICreditManagerV3.sol @@ -121,11 +121,10 @@ interface ICreditManagerV3 is IVersion, ICreditManagerV3Events { function liquidateCreditAccount( address creditAccount, CollateralDebtData calldata collateralDebtData, - address payer, address to, - uint256 skipTokensMask, + uint256 tokensToTransferMask, bool convertToETH, - bool isExpiredLiquidation + bool isExpired ) external returns (uint256 remainingFunds, uint256 loss); function manageDebt(address creditAccount, uint256 amount, uint256 enabledTokensMask, ManageDebtAction action) diff --git a/contracts/interfaces/IExceptions.sol b/contracts/interfaces/IExceptions.sol index cc77bace..1438f8ea 100644 --- a/contracts/interfaces/IExceptions.sol +++ b/contracts/interfaces/IExceptions.sol @@ -116,6 +116,9 @@ error IncreaseQuotaOnZeroDebtAccountException(); /// @notice Thrown when attempting to close an account with non-zero debt error CloseAccountWithNonZeroDebtException(); +/// @notice Thrown when value of funds remaining on the account after liquidation is insufficient +error InsufficientRemainingFundsException(); + /// @notice Thrown when Credit Facade tries to write over a non-zero active Credit Account error ActiveCreditAccountOverridenException(); diff --git a/contracts/libraries/CollateralLogic.sol b/contracts/libraries/CollateralLogic.sol index 53bba80b..f897c53b 100644 --- a/contracts/libraries/CollateralLogic.sol +++ b/contracts/libraries/CollateralLogic.sol @@ -284,12 +284,8 @@ library CollateralLogic { address priceOracle, uint256 tokensToCheckMask ) internal view returns (uint256 totalValueUSD) { - uint256 totalValueUSD; - while (tokensToCheckMask != 0) { - uint256 tokenMask; - tokenMask = tokensToCheckMask & uint256(-int256(tokensToCheckMask)); - + uint256 tokenMask = tokensToCheckMask & uint256(-int256(tokensToCheckMask)); (address token,) = collateralTokenByMaskFn(tokenMask, false); uint256 balance = IERC20(token).safeBalanceOf({account: creditAccount}); diff --git a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol index aa3c17a1..3b888196 100644 --- a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol @@ -217,7 +217,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditFacade.liquidateCreditAccount({ creditAccount: DUMB_ADDRESS, to: DUMB_ADDRESS, - skipTokensMask: 0, + tokensToTransferMask: 1, convertToETH: false, calls: new MultiCall[](0) }); @@ -271,7 +271,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditFacade.liquidateCreditAccount({ creditAccount: DUMB_ADDRESS, to: DUMB_ADDRESS, - skipTokensMask: 0, + tokensToTransferMask: 1, convertToETH: false, calls: new MultiCall[](0) }); @@ -310,7 +310,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditFacade.liquidateCreditAccount({ creditAccount: DUMB_ADDRESS, to: DUMB_ADDRESS, - skipTokensMask: 0, + tokensToTransferMask: 1, convertToETH: false, calls: new MultiCall[](0) });