From 18d29068f68bb664963195c25689f544adc7df46 Mon Sep 17 00:00:00 2001 From: Van0k Date: Tue, 14 Feb 2023 15:22:22 +0400 Subject: [PATCH] feat: credit manager with quotas draft --- contracts/credit/CreditManager.sol | 650 +++++++++++++++--- contracts/interfaces/ICreditManagerV2.sol | 7 +- contracts/interfaces/IPool4626.sol | 6 + contracts/pool/Pool4626.sol | 8 + contracts/test/credit/CreditFacade.t.sol | 5 +- contracts/test/credit/CreditManager.t.sol | 44 +- .../credit/CreditManagerTestInternal.sol | 21 +- 7 files changed, 626 insertions(+), 115 deletions(-) diff --git a/contracts/credit/CreditManager.sol b/contracts/credit/CreditManager.sol index 49243e2..aef2019 100644 --- a/contracts/credit/CreditManager.sol +++ b/contracts/credit/CreditManager.sol @@ -15,10 +15,12 @@ import { ACLNonReentrantTrait } from "../core/ACLNonReentrantTrait.sol"; import { IAccountFactory } from "../interfaces/IAccountFactory.sol"; import { ICreditAccount } from "../interfaces/ICreditAccount.sol"; import { IPoolService } from "../interfaces/IPoolService.sol"; +import { IPool4626, QuotaUpdate } from "../interfaces/IPool4626.sol"; import { IWETHGateway } from "../interfaces/IWETHGateway.sol"; import { ICreditManagerV2, ClosureAction } from "../interfaces/ICreditManagerV2.sol"; import { IAddressProvider } from "../interfaces/IAddressProvider.sol"; import { IPriceOracleV2 } from "../interfaces/IPriceOracle.sol"; +import { IVersion } from "../interfaces/IVersion.sol"; // CONSTANTS import { RAY } from "../libraries/Constants.sol"; @@ -48,6 +50,13 @@ struct Slot1 { uint16 ltUnderlying; } +struct CreditAccountQuotaData { + /// @dev Quota received by the user for a token, denominated in underlying + uint96 quotaAmount; + /// @dev Last fee cumulative index recorded for this CA + uint192 cumulativeIndexCA_LU; +} + /// @title Credit Manager /// @notice Encapsulates the business logic for managing Credit Accounts /// @@ -142,8 +151,28 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { /// @notice See more at https://dev.gearbox.fi/docs/documentation/integrations/universal address public universalAdapter; + /// QUOTA-RELATED PARAMS + + /// @dev Whether the CM supports quota-related logic + bool public immutable supportsQuotas; + + /// @dev DAO fee on quotas in PERCENTAGE_FACTOR format + uint16 public daoFeeQuotas; + + /// @dev Mask of tokens to apply quotas for + uint256 public limitedTokenMask; + + /// @dev Quotas per credit account and token + mapping(address => mapping(address => CreditAccountQuotaData)) + public quotas; + + /// @dev The latest accumulated fees for Credit Accounts + mapping(address => uint256) public quotaFeesLU; + + /// VERSION + /// @dev contract version - uint256 public constant override version = 2; + uint256 public constant override version = 2_1; // // MODIFIERS @@ -193,6 +222,10 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { address _underlying = IPoolService(pool).underlyingToken(); // F:[CM-1] underlying = _underlying; // F:[CM-1] + supportsQuotas = IVersion(pool).version() == 2_1 + ? IPool4626(pool).supportQuotaPremiums() + : false; + // The underlying is the first token added as collateral _addToken(_underlying); // F:[CM-1] @@ -318,17 +351,19 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { uint256 profit; uint256 loss; uint256 borrowedAmountWithInterest; + uint256 borrowedAmountWithInterestAndFees; ( borrowedAmount, borrowedAmountWithInterest, - + borrowedAmountWithInterestAndFees ) = calcCreditAccountAccruedInterest(creditAccount); // F: (amountToPool, remainingFunds, profit, loss) = calcClosePayments( totalValue, closureActionType, borrowedAmount, - borrowedAmountWithInterest + borrowedAmountWithInterest, + borrowedAmountWithInterestAndFees ); // F:[CM-10,11,12] uint256 underlyingBalance = IERC20(underlying).balanceOf( @@ -383,15 +418,27 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { ); // F:[CM-13,18] } - // Tokens in skipTokenMask are disabled before transferring all assets - uint256 enabledTokensMask = enabledTokensMap[creditAccount] & - ~skipTokenMask; // F:[CM-14] - _transferAssetsTo(creditAccount, to, convertWETH, enabledTokensMask); // F:[CM-14,17,19] + _beforeAccountClosure( + creditAccount, + to, + convertWETH, + enabledTokensMap[creditAccount], + skipTokenMask + ); // Returns Credit Account to the factory _accountFactory.returnCreditAccount(creditAccount); // F:[CM-9] } + struct RepaymentParams { + uint256 interestAccrued; + uint256 interestProfit; + uint256 quotaFees; + uint256 quotaFeesProfit; + uint256 repaidAmount; + uint256 profitAmount; + } + /// @dev Manages debt size for borrower: /// /// - Increase debt: @@ -443,38 +490,45 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // Requests the pool to lend additional funds to the Credit Account IPoolService(pool).lendCreditAccount(amount, creditAccount); // F:[CM-20] } else { + // When the user reduces debt, the various components of debt + // are repaid in the following order: + // * Interest on principal and interest fees + // * Quota fees to the pool and dao + // * Principal + + RepaymentParams memory params; + // Computes the interest accrued thus far - uint256 interestAccrued = (borrowedAmount * - cumulativeIndexNow_RAY) / + params.interestAccrued = + (borrowedAmount * cumulativeIndexNow_RAY) / cumulativeIndexAtOpen_RAY - borrowedAmount; // F:[CM-21] // Computes profit, taken as a percentage of the interest rate - uint256 profit = (interestAccrued * slot1.feeInterest) / + params.interestProfit = + (params.interestAccrued * slot1.feeInterest) / PERCENTAGE_FACTOR; // F:[CM-21] - if (amount >= interestAccrued + profit) { - // If the amount covers all of the interest and fees, they are - // paid first, and the remainder is used to pay the principal - newBorrowedAmount = - borrowedAmount + - interestAccrued + - profit - - amount; - - // Pays the amount back to the pool - ICreditAccount(creditAccount).safeTransfer( - underlying, - pool, - amount - ); // F:[CM-21] + // Records all outstanding quota fees to the pool and DAO + if (supportsQuotas) { + params.quotaFees = _accrueQuotaFees(creditAccount); + params.quotaFeesProfit = + (params.quotaFees * daoFeeQuotas) / + PERCENTAGE_FACTOR; + } - // Signals the pool that the debt was partially repaid - IPoolService(pool).repayCreditAccount( - amount - interestAccrued - profit, - profit, - 0 - ); // F:[CM-21] + params.repaidAmount = amount; + + if ( + params.repaidAmount >= + params.interestAccrued + params.interestProfit + ) { + // The amount fully covers interest and interest fee, + // so we subtract them from the remainder + params.repaidAmount -= + params.interestAccrued + + params.interestProfit; + params.profitAmount += params.interestProfit; // Since interest is fully repaid, the Credit Account's cumulativeIndexAtOpen // is set to the current cumulative index - which means interest starts accruing @@ -490,23 +544,14 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { (PERCENTAGE_FACTOR + slot1.feeInterest); uint256 amountToFees = amount - amountToInterest; - // Since interest and fees are paid out first, the principal - // remains unchanged - newBorrowedAmount = borrowedAmount; - - // Pays the amount back to the pool - ICreditAccount(creditAccount).safeTransfer( - underlying, - pool, - amount - ); // F:[CM-21] - - // Signals the pool that the debt was partially repaid - IPoolService(pool).repayCreditAccount(0, amountToFees, 0); // F:[CM-21] + // The entire amount was spent on interest, so nothing remains for + // quotas or principal + params.repaidAmount = 0; + params.profitAmount += amountToFees; - // Since the interest was only repaid partially, we need to recompute the - // cumulativeIndexAtOpen, so that "borrowAmount * (indexNow / indexAtOpenNew - 1)" - // is equal to interestAccrued - amountToInterest + // // Since the interest was only repaid partially, we need to recompute the + // // cumulativeIndexAtOpen, so that "borrowAmount * (indexNow / indexAtOpenNew - 1)" + // // is equal to interestAccrued - amountToInterest newCumulativeIndex = _calcNewCumulativeIndex( borrowedAmount, @@ -516,6 +561,49 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { false ); } + + if (supportsQuotas) { + if ( + params.repaidAmount >= + params.quotaFees + params.quotaFeesProfit + ) { + // If the remainging amount covers all quota fees to pool and DAO, we subtract them from the repaid amount + // and set recorded fees to 0 (since outstanding fees were recorded beforehand, all quota fees are now 0) + + params.repaidAmount -= + params.quotaFees + + params.quotaFeesProfit; + params.profitAmount += params.quotaFeesProfit; + quotaFeesLU[creditAccount] = 0; + } else if (params.repaidAmount != 0) { + // If the remaining amount is not enough to cover both, then we split pro-rata between pool and DAO profit, + // similarly to primary interest + uint256 quotaFeesToPool = (params.repaidAmount * + PERCENTAGE_FACTOR) / (PERCENTAGE_FACTOR + daoFeeQuotas); + uint256 quotaFeesToDAO = params.repaidAmount - + quotaFeesToPool; + + params.repaidAmount = 0; + params.profitAmount += quotaFeesToDAO; + quotaFeesLU[creditAccount] -= quotaFeesToPool; + } + } + + // Finally, we transfer the amount to pool and signal repayment + // Everything that remains after paying interest / fees goes to principal + ICreditAccount(creditAccount).safeTransfer( + underlying, + pool, + amount + ); // F:[CM-21] + + IPoolService(pool).repayCreditAccount( + params.repaidAmount, + params.profitAmount, + 0 + ); // F:[CM-21] + + newBorrowedAmount = borrowedAmount - params.repaidAmount; } // // Sets new parameters on the Credit Account @@ -755,17 +843,19 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { { uint256 tokenMask = tokenMasksMap(token); // F:[CM-30,31] - // Checks that the token is valid collateral recognized by the system - // and that it is not forbidden - if (tokenMask == 0 || forbiddenTokenMask & tokenMask != 0) { - revert TokenNotAllowedException(); - } // F:[CM-30] - - // Performs an inclusion check using token masks, - // to avoid accidentally disabling the token - if (enabledTokensMap[creditAccount] & tokenMask == 0) { - enabledTokensMap[creditAccount] |= tokenMask; - } // F:[CM-31] + if (!supportsQuotas || tokenMask & limitedTokenMask == 0) { + // Checks that the token is valid collateral recognized by the system + // and that it is not forbidden + if (tokenMask == 0 || forbiddenTokenMask & tokenMask != 0) { + revert TokenNotAllowedException(); + } // F:[CM-30] + + // Performs an inclusion check using token masks, + // to avoid accidentally disabling the token + if (enabledTokensMap[creditAccount] & tokenMask == 0) { + enabledTokensMap[creditAccount] |= tokenMask; + } // F:[CM-31] + } } /// @dev Optimized health check for individual swap-like operations. @@ -801,14 +891,18 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { uint256 balanceInAfter = IERC20(tokenIn).balanceOf(creditAccount); // F: [CM-34] uint256 balanceOutAfter = IERC20(tokenOut).balanceOf(creditAccount); // F: [CM-34] - (uint256 amountInCollateral, uint256 amountOutCollateral) = slot1 - .priceOracle - .fastCheck( - balanceInBefore - balanceInAfter, + ( + uint256 amountInCollateral, + uint256 amountOutCollateral + ) = _getEffectiveCollateralDeltas( + creditAccount, tokenIn, - balanceOutAfter - balanceOutBefore, - tokenOut - ); // F:[CM-34] + tokenOut, + balanceInBefore, + balanceOutBefore, + balanceInAfter, + balanceOutAfter + ); // Disables tokenIn if the entire balance was spent by the operation if (balanceInAfter <= 1) _disableToken(creditAccount, tokenIn); // F:[CM-33] @@ -852,6 +946,63 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { cumulativeDropAtFastCheckRAY[creditAccount] = 1; // F:[CM-36] } + function _getEffectiveCollateralDeltas( + address creditAccount, + address tokenIn, + address tokenOut, + uint256 balanceInBefore, + uint256 balanceOutBefore, + uint256 balanceInAfter, + uint256 balanceOutAfter + ) + internal + view + returns (uint256 amountInCollateral, uint256 amountOutCollateral) + { + if ( + !supportsQuotas || + ((tokenMasksMap(tokenIn) | tokenMasksMap(tokenOut)) & + limitedTokenMask == + 0) + ) { + (amountInCollateral, amountOutCollateral) = slot1 + .priceOracle + .fastCheck( + balanceInBefore - balanceInAfter, + tokenIn, + balanceOutAfter - balanceOutBefore, + tokenOut + ); // F:[CM-34] + } else { + (uint256 valueBefore, uint256 valueAfter) = slot1 + .priceOracle + .fastCheck(balanceInBefore, tokenIn, balanceInAfter, tokenIn); + + if (tokenMasksMap(tokenIn) & limitedTokenMask != 0) { + uint256 quota = quotas[creditAccount][tokenIn].quotaAmount; + valueBefore = valueBefore > quota ? quota : valueBefore; + amountInCollateral = valueBefore - valueAfter; + } else { + amountInCollateral = valueBefore - valueAfter; + } + + (valueBefore, valueAfter) = slot1.priceOracle.fastCheck( + balanceOutBefore, + tokenOut, + balanceOutAfter, + tokenOut + ); + + if (tokenMasksMap(tokenOut) & limitedTokenMask != 0) { + uint256 quota = quotas[creditAccount][tokenOut].quotaAmount; + valueAfter = valueAfter > quota ? quota : valueAfter; + amountOutCollateral = valueAfter - valueBefore; + } else { + amountOutCollateral = valueAfter - valueBefore; + } + } + } + /// @dev Performs a full health check on an account, summing up /// value of all enabled collateral tokens /// @param creditAccount Address of the Credit Account to check @@ -914,7 +1065,13 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // Collateral calculations are only done if there is a non-zero balance if (balance > 1) { twvUSD += - _priceOracle.convertToUSD(balance, token) * + _getEffectiveValue( + creditAccount, + token, + balance, + _priceOracle, + tokenMask + ) * liquidationThreshold; // Full collateral check evaluates a Credit Account's health factor lazily; @@ -959,9 +1116,14 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // Zero-balance tokens are disabled; this is done by flipping the // bit in enabledTokenMask, which is then written into storage at the // very end, to avoid redundant storage writes + + // Since limited tokens are disabled based on their assigned quotas, + // they are skipped here } else { - enabledTokenMask ^= tokenMask; // F:[CM-39] - atLeastOneTokenWasDisabled = true; // F:[CM-39] + if (tokenMask & limitedTokenMask == 0) { + enabledTokenMask ^= tokenMask; // F:[CM-39] + atLeastOneTokenWasDisabled = true; // F:[CM-39] + } } } @@ -972,6 +1134,24 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { revert NotEnoughCollateralException(); } + /// @dev Gets the effective value (i.e., value in underlying included into TWV) for a token on an account + function _getEffectiveValue( + address creditAccount, + address token, + uint256 balance, + IPriceOracleV2 _priceOracle, + uint256 tokenMask + ) internal view returns (uint256 effectiveValue) { + if (!supportsQuotas || tokenMask & limitedTokenMask != 0) { + effectiveValue = _priceOracle.convertToUSD(balance, token); + } else { + uint256 value = _priceOracle.convertToUSD(balance, token); + uint256 quota = quotas[creditAccount][token].quotaAmount; + + effectiveValue = value > quota ? quota : value; + } + } + /// @dev Checks that the number of enabled tokens on a Credit Account /// does not violate the maximal enabled token limit and tries /// to disable unused tokens if it does @@ -1046,7 +1226,7 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { unchecked { for (uint256 i = minIndex; i < maxIndex; ) { uint256 tokenMask = 1 << i; - if (enabledTokenMask & tokenMask != 0) { + if (enabledTokenMask & tokenMask & ~limitedTokenMask != 0) { (address token, ) = collateralTokensByMask(tokenMask); uint256 balance = IERC20(token).balanceOf(creditAccount); @@ -1091,12 +1271,208 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // The enabled token mask encodes all enabled tokens as 1, // therefore the corresponding bit is set to 0 to disable it uint256 tokenMask = tokenMasksMap(token); - if (enabledTokensMap[creditAccount] & tokenMask != 0) { + + if ( + (!supportsQuotas || tokenMask & limitedTokenMask == 0) && + (enabledTokensMap[creditAccount] & tokenMask != 0) + ) { enabledTokensMap[creditAccount] &= ~tokenMask; // F:[CM-46] wasChanged = true; } } + /// QUOTA CONTROLS + + /// @dev Updates a single quota for a Credit Account + /// @param creditAccount Credit Account to update the quota for + /// @param update Struct containing the update data: + /// * token - token to update the quota for + /// * quotaChange - the requested new amount for the quota (NB: not delta) + function updateQuota(address creditAccount, QuotaUpdate memory update) + external + creditFacadeOnly + nonReentrant + { + if (!supportsQuotas) { + revert CMDoesNotSupportQuotasException(); + } + _updateQuota(creditAccount, update); + } + + /// @dev Updates a single quota for a Credit Account + /// @param creditAccount Credit Account to update the quota for + /// @param updates Array of structs containing the update data: + /// * token - token to update the quota for + /// * quotaChange - the requested new amount for the quota (NB: not delta) + function updateQuotas(address creditAccount, QuotaUpdate[] memory updates) + external + creditFacadeOnly + nonReentrant + { + if (!supportsQuotas) { + revert CMDoesNotSupportQuotasException(); + } + _updateQuotas(creditAccount, updates); + } + + /// @dev IMPLEMENTATION: updateQuota + /// @notice Performs operations necessary to update the quota: + /// * Accrues all outstanding quota fees so that future fees are accrued to the new amount + /// * Requests an update from the pool + /// * Applies the returned actual delta from the pool (if the total quotas are close to the limit, + /// the actual delta may be lower than existing delta) + function _updateQuota(address creditAccount, QuotaUpdate memory update) + internal + { + CreditAccountQuotaData memory q = quotas[creditAccount][update.token]; + + /// First, we need to accrue existing fees, if there were a different quota amount previously + uint256 cumulativeIndexNow = IPool4626(pool).quotaCumulativeIndex( + update.token + ); + if (q.quotaAmount != 0) { + uint256 accruedFee = (uint256(q.quotaAmount) * cumulativeIndexNow) / + q.cumulativeIndexCA_LU - + uint256(q.quotaAmount); + quotaFeesLU[creditAccount] += accruedFee; + } + q.cumulativeIndexCA_LU = uint192(cumulativeIndexNow); + + /// Then we update the quota amount + int96 newAmount = update.quotaChange == type(int96).max + ? int96(uint96(IERC20(update.token).balanceOf(creditAccount))) + : update.quotaChange; + + int96 requestedDelta = newAmount - int96(q.quotaAmount); + + int96 actualDelta = IPool4626(pool).updateQuota( + update.token, + requestedDelta + ); + + newAmount = int96(q.quotaAmount) + actualDelta; + + if (newAmount > 0 && q.quotaAmount == 0) { + uint256 tokenMask = tokenMasksMap(update.token); + enabledTokensMap[creditAccount] |= tokenMask; + } else if (newAmount == 0 && q.quotaAmount > 0) { + uint256 tokenMask = tokenMasksMap(update.token); + enabledTokensMap[creditAccount] &= ~tokenMask; + } + + q.quotaAmount = uint96(newAmount); + + quotas[creditAccount][update.token] = q; + } + + function _updateQuotas(address creditAccount, QuotaUpdate[] memory updates) + internal + { + uint256 len = updates.length; + + CreditAccountQuotaData[] memory q = new CreditAccountQuotaData[](len); + + for (uint256 i = 0; i < len; ) { + q[i] = quotas[creditAccount][updates[i].token]; + + uint256 cumulativeIndexNow = IPool4626(pool).quotaCumulativeIndex( + updates[i].token + ); + if (q[i].quotaAmount != 0) { + uint256 accruedFee = (uint256(q[i].quotaAmount) * + cumulativeIndexNow) / + q[i].cumulativeIndexCA_LU - + uint256(q[i].quotaAmount); + quotaFeesLU[creditAccount] += accruedFee; + } + q[i].cumulativeIndexCA_LU = uint192(cumulativeIndexNow); + + updates[i].quotaChange = + ( + updates[i].quotaChange == type(int96).max + ? int96( + uint96( + IERC20(updates[i].token).balanceOf( + creditAccount + ) + ) + ) + : updates[i].quotaChange + ) - + int96(q[i].quotaAmount); + + unchecked { + ++i; + } + } + + int96[] memory actualDeltas = IPool4626(pool).updateQuotas(updates); + uint256 tokensToEnableMask = 0; + uint256 tokensToDisableMask = type(uint256).max; + + for (uint256 i = 0; i < len; ) { + int96 newAmount = int96(q[i].quotaAmount) + actualDeltas[i]; + + if (newAmount > 0 && q[i].quotaAmount == 0) { + uint256 tokenMask = tokenMasksMap(updates[i].token); + tokensToEnableMask |= tokenMask; + } else if (newAmount == 0 && q[i].quotaAmount > 0) { + uint256 tokenMask = tokenMasksMap(updates[i].token); + tokensToDisableMask &= ~tokenMask; + } + + q[i].quotaAmount = uint96(newAmount); + + quotas[creditAccount][updates[i].token] = q[i]; + + unchecked { + ++i; + } + } + + enabledTokensMap[creditAccount] = + (enabledTokensMap[creditAccount] | tokensToEnableMask) & + ~tokensToDisableMask; + } + + /// @dev Records all outstanding quota fees for a credit account + function _accrueQuotaFees(address creditAccount) + internal + returns (uint256) + { + uint256 enabledLimitedTokens = enabledTokensMap[creditAccount] & + limitedTokenMask; + uint256 outstandingFees = 0; + + uint256 tokenMask = 2; + + while (tokenMask <= enabledLimitedTokens) { + if (enabledLimitedTokens & tokenMask != 0) { + (address token, ) = collateralTokensByMask(tokenMask); + + CreditAccountQuotaData storage q = quotas[creditAccount][token]; + + uint256 cumulativeIndexNow = IPool4626(pool) + .quotaCumulativeIndex(token); + + outstandingFees += + (uint256(q.quotaAmount) * cumulativeIndexNow) / + q.cumulativeIndexCA_LU - + uint256(q.quotaAmount); + + quotas[creditAccount][token].cumulativeIndexCA_LU = uint192( + cumulativeIndexNow + ); + } + + tokenMask = tokenMask << 1; + } + + quotaFeesLU[creditAccount] += outstandingFees; + + return quotaFeesLU[creditAccount]; + } + /// @dev Checks if the contract is paused; if true, checks that the caller is emergency liquidator /// and temporarily enables a special emergencyLiquidator mode to allow liquidation. /// @notice Some whenNotPausedOrEmergency functions in CreditManager need to be executable to perform @@ -1127,23 +1503,35 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // INTERNAL HELPERS // - /// @dev Transfers all enabled assets from a Credit Account to the "to" address - /// @param creditAccount Credit Account to transfer assets from - /// @param to Recipient address + /// @dev Performs actions that need to be done before account closure: + /// * Set all active coverages to 0 + /// * Transfer all remaining assets to the "remainingAssetRecipient" address + /// @param creditAccount Credit Account to perform actions for + /// @param remainingAssetRecipient Address to send remaining assets to /// @param convertWETH Whether WETH must be converted to ETH before sending - /// @param enabledTokensMask A bit mask encoding enabled tokens. All of the tokens included - /// in the mask will be transferred. If any tokens need to be skipped, they must be - /// excluded from the mask beforehand. - function _transferAssetsTo( + /// @param enabledTokensMask A bit mask encoding enabled tokens. + /// @param skipTokenMask A bit mask encoding tokens that need to be skipped when transferring assets + function _beforeAccountClosure( address creditAccount, - address to, + address remainingAssetRecipient, bool convertWETH, - uint256 enabledTokensMask + uint256 enabledTokensMask, + uint256 skipTokenMask ) internal { - // Since underlying should have been transferred to "to" before this function is called - // (if there is a surplus), its tokenMask of 1 is skipped + // Since no additional actions are needed for the underlying + // after main closure logic, it is skipped in the finalization cycle uint256 tokenMask = 2; + QuotaUpdate[] memory updates; + uint256 quotaIndex; + + if (supportsQuotas) { + updates = new QuotaUpdate[]( + _calcEnabledTokens(enabledTokensMask & limitedTokenMask) + ); + quotaIndex = 0; + } + // Since enabledTokensMask encodes all enabled tokens as 1, // tokenMask > enabledTokensMask is equivalent to the last 1 bit being passed // The loop can be ended at this point @@ -1152,8 +1540,20 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // and 0 otherwise if (enabledTokensMask & tokenMask != 0) { (address token, ) = collateralTokensByMask(tokenMask); // F:[CM-44] + + // If a limited token is enabled, it has a non-zero coverage amount, + // so we need to set coverage to 0 and notify pool + if (supportsQuotas && limitedTokenMask & tokenMask != 0) { + updates[quotaIndex].token = token; + updates[quotaIndex].quotaChange = -int96( + quotas[creditAccount][token].quotaAmount + ); + quotas[creditAccount][token].quotaAmount = 0; + ++quotaIndex; + } + uint256 amount = IERC20(token).balanceOf(creditAccount); // F:[CM-44] - if (amount > 1) { + if (amount > 1 && tokenMask & skipTokenMask == 0) { // 1 is subtracted from amount to leave a non-zero value // in the balance mapping, optimizing future writes // Since the amount is checked to be more than 1, @@ -1164,7 +1564,7 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { _safeTokenTransfer( creditAccount, token, - to, + remainingAssetRecipient, amount - 1, convertWETH ); // F:[CM-44] @@ -1176,6 +1576,7 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { // which corresponds to moving on to the next token tokenMask = tokenMask << 1; // F:[CM-44] } + if (supportsQuotas) IPool4626(pool).updateQuotas(updates); } /// @dev Requests the Credit Account to transfer a token to another address @@ -1229,6 +1630,7 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { /// * LIQUIDATE_PAUSED: The account is liquidated while the system is paused due to emergency (no liquidation premium) /// @param borrowedAmount Credit Account's debt principal /// @param borrowedAmountWithInterest Credit Account's debt principal + interest + /// @param borrowedAmountWithInterestAndFees Credit Account's debt principal + interest + fees /// @return amountToPool Amount of underlying to be sent to the pool /// @return remainingFunds Amount of underlying to be sent to the borrower (only applicable to liquidations) /// @return profit Protocol's profit from fees (if any) @@ -1237,7 +1639,8 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { uint256 totalValue, ClosureAction closureActionType, uint256 borrowedAmount, - uint256 borrowedAmountWithInterest + uint256 borrowedAmountWithInterest, + uint256 borrowedAmountWithInterestAndFees ) public view @@ -1249,14 +1652,7 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { uint256 loss ) { - // The amount to be paid to pool is computed with fees included - // The pool will compute the amount of Diesel tokens to treasury - // based on profit - amountToPool = - borrowedAmountWithInterest + - ((borrowedAmountWithInterest - borrowedAmount) * - slot1.feeInterest) / - PERCENTAGE_FACTOR; // F:[CM-43] + amountToPool = borrowedAmountWithInterestAndFees; if ( closureActionType == ClosureAction.LIQUIDATE_ACCOUNT || @@ -1425,19 +1821,59 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { cumulativeIndexNow_RAY ) = _getCreditAccountParameters(creditAccount); // F:[CM-49] - // Interest is never stored and is always computed dynamically - // as the difference between the current cumulative index of the pool - // and the cumulative index recorded in the Credit Account + uint256 quotaFees = calcQuotaFees(creditAccount); + borrowedAmountWithInterest = (borrowedAmount * cumulativeIndexNow_RAY) / cumulativeIndexAtOpen_RAY; // F:[CM-49] - // Fees are computed as a percentage of interest + uint256 daoFees = ((borrowedAmountWithInterest - borrowedAmount) * + slot1.feeInterest) / PERCENTAGE_FACTOR; + + if (supportsQuotas) { + daoFees += (quotaFees * daoFeeQuotas) / PERCENTAGE_FACTOR; + } + + borrowedAmountWithInterest += quotaFees; borrowedAmountWithInterestAndFees = borrowedAmountWithInterest + - ((borrowedAmountWithInterest - borrowedAmount) * - slot1.feeInterest) / - PERCENTAGE_FACTOR; // F: [CM-49] + daoFees; + } + + function calcQuotaFees(address creditAccount) + public + view + returns (uint256) + { + if (!supportsQuotas) { + return 0; + } else { + uint256 enabledLimitedTokens = enabledTokensMap[creditAccount] & + limitedTokenMask; + uint256 outstandingFees = 0; + + uint256 tokenMask = 2; + + while (tokenMask <= enabledLimitedTokens) { + if (enabledLimitedTokens & tokenMask != 0) { + (address token, ) = collateralTokensByMask(tokenMask); + + CreditAccountQuotaData storage q = quotas[creditAccount][ + token + ]; + + outstandingFees += + (uint256(q.quotaAmount) * + IPool4626(pool).quotaCumulativeIndex(token)) / + q.cumulativeIndexCA_LU - + uint256(q.quotaAmount); + } + + tokenMask = tokenMask << 1; + } + + return quotaFeesLU[creditAccount] + outstandingFees; + } } /// @dev Returns the parameters of the Credit Account required to calculate debt @@ -1656,6 +2092,22 @@ contract CreditManager is ICreditManagerV2, ACLNonReentrantTrait { forbiddenTokenMask = _forbidMask; // F:[CM-55] } + /// @dev Sets the limited token mask + /// @param _limitedMask The new bit mask encoding the set of limited tokens + /// @notice Limited tokens are counted as collateral based on their quotas assigned by users. + /// Quotas are denominated in underlying and represent the ceiling for respective collateral's value + /// They also accrue additional fees + function setLimitedMask(uint256 _limitedMask) + external + creditConfiguratorOnly + { + limitedTokenMask = _limitedMask; + } + + function setDAOQuotasFee(uint16 _newFee) external creditConfiguratorOnly { + daoFeeQuotas = _newFee; + } + /// @dev Sets the maximal number of enabled tokens on a single Credit Account. /// @param newMaxEnabledTokens The new enabled token limit. function setMaxEnabledTokens(uint8 newMaxEnabledTokens) diff --git a/contracts/interfaces/ICreditManagerV2.sol b/contracts/interfaces/ICreditManagerV2.sol index 4039785..6499ed2 100644 --- a/contracts/interfaces/ICreditManagerV2.sol +++ b/contracts/interfaces/ICreditManagerV2.sol @@ -67,6 +67,9 @@ interface ICreditManagerV2Exceptions { /// @dev Thrown when a reentrancy into the contract is attempted error ReentrancyLockException(); + + /// @dev Thrown when attempting to access quota logic when the CM does not support quotas + error CMDoesNotSupportQuotasException(); } /// @notice All Credit Manager functions are access-restricted and can only be called @@ -261,6 +264,7 @@ interface ICreditManagerV2 is /// * LIQUIDATE_PAUSED: The account is liquidated while the system is paused due to emergency (no liquidation premium) /// @param borrowedAmount Credit Account's debt principal /// @param borrowedAmountWithInterest Credit Account's debt principal + interest + /// @param borrowedAmountWithInterestAndFees Credit Account's debt principal + interest + fees /// @return amountToPool Amount of underlying to be sent to the pool /// @return remainingFunds Amount of underlying to be sent to the borrower (only applicable to liquidations) /// @return profit Protocol's profit from fees (if any) @@ -269,7 +273,8 @@ interface ICreditManagerV2 is uint256 totalValue, ClosureAction closureActionType, uint256 borrowedAmount, - uint256 borrowedAmountWithInterest + uint256 borrowedAmountWithInterest, + uint256 borrowedAmountWithInterestAndFees ) external view diff --git a/contracts/interfaces/IPool4626.sol b/contracts/interfaces/IPool4626.sol index 70fda2a..71edf6b 100644 --- a/contracts/interfaces/IPool4626.sol +++ b/contracts/interfaces/IPool4626.sol @@ -172,6 +172,9 @@ interface IPool4626 is /// @dev diesel rate in RAY format function getDieselRate_RAY() external view returns (uint256); + /// @dev Cumulative index for quota fees for a token (returns 0 if quotas are not supported) + function quotaCumulativeIndex(address) external view returns (uint256); + /// @dev Address of the underlying function underlyingToken() external view returns (address); @@ -184,6 +187,9 @@ interface IPool4626 is /// @dev Borrow limit for particular credit manager function creditManagerLimit(address) external view returns (uint256); + /// @dev Whether the pool supports quota premiums + function supportQuotaPremiums() external view returns (bool); + /// @dev Withdrawal fee function withdrawFee() external view returns (uint16); diff --git a/contracts/pool/Pool4626.sol b/contracts/pool/Pool4626.sol index 0ba4c9c..1d04c1c 100644 --- a/contracts/pool/Pool4626.sol +++ b/contracts/pool/Pool4626.sol @@ -1022,6 +1022,14 @@ contract Pool4626 is ERC20, IPool4626, ACLNonReentrantTrait { return uint256(_totalBorrowed); } + function quotaCumulativeIndex(address token) + external + view + returns (uint256) + { + return supportQuotaPremiums ? IGauge(gauge).cumulativeIndex(token) : 0; + } + // // CONFIGURATION // diff --git a/contracts/test/credit/CreditFacade.t.sol b/contracts/test/credit/CreditFacade.t.sol index f9a3da6..33a99eb 100644 --- a/contracts/test/credit/CreditFacade.t.sol +++ b/contracts/test/credit/CreditFacade.t.sol @@ -2477,14 +2477,15 @@ contract CreditFacadeTest is ( uint256 borrowedAmount, uint256 borrowedAmountWithInterest, - + uint256 borrowedAmountWithInterestAndFees ) = creditManager.calcCreditAccountAccruedInterest(creditAccount); (, uint256 remainingFunds, , ) = creditManager.calcClosePayments( balance, ClosureAction.LIQUIDATE_EXPIRED_ACCOUNT, borrowedAmount, - borrowedAmountWithInterest + borrowedAmountWithInterest, + borrowedAmountWithInterestAndFees ); // EXPECTED STACK TRACE & EVENTS diff --git a/contracts/test/credit/CreditManager.t.sol b/contracts/test/credit/CreditManager.t.sol index a3ddd92..49b110f 100644 --- a/contracts/test/credit/CreditManager.t.sol +++ b/contracts/test/credit/CreditManager.t.sol @@ -2684,12 +2684,14 @@ contract CreditManagerTest is // // CALC CLOSE PAYMENT PURE // + struct CalcClosePaymentsPureTestCase { string name; uint256 totalValue; ClosureAction closureActionType; uint256 borrowedAmount; uint256 borrowedAmountWithInterest; + uint256 borrowedAmountWithInterestAndFees; uint256 amountToPool; uint256 remainingFunds; uint256 profit; @@ -2715,6 +2717,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.CLOSE_ACCOUNT, borrowedAmount: 1000, borrowedAmountWithInterest: 1100, + borrowedAmountWithInterestAndFees: 1110, amountToPool: 1110, // amountToPool = 1100 + 100 * 10% = 1110 remainingFunds: 0, profit: 10, // profit: 100 (interest) * 10% = 10 @@ -2726,6 +2729,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_ACCOUNT, borrowedAmount: 1000, borrowedAmountWithInterest: 1100, + borrowedAmountWithInterestAndFees: 1110, amountToPool: 1150, // amountToPool = 1100 + 100 * 10% + 2000 * 2% = 1150 remainingFunds: 749, //remainingFunds: 2000 * (100% - 5%) - 1150 - 1 = 749 profit: 50, @@ -2737,6 +2741,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_ACCOUNT, borrowedAmount: 900, borrowedAmountWithInterest: 1900, + borrowedAmountWithInterestAndFees: 2000, amountToPool: 1995, // amountToPool = 1900 + 1000 * 10% + 2100 * 2% = 2042, totalFunds = 2100 * 95% = 1995, so, amount to pool would be 1995 remainingFunds: 0, // remainingFunds: 2000 * (100% - 5%) - 1150 - 1 = 749 profit: 95, @@ -2748,6 +2753,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_ACCOUNT, borrowedAmount: 900, borrowedAmountWithInterest: 1900, + borrowedAmountWithInterestAndFees: 2000, amountToPool: 950, // amountToPool = 1900 + 1000 * 10% + 1000 * 2% = 2020, totalFunds = 1000 * 95% = 950, So, amount to pool would be 950 remainingFunds: 0, // 0, cause it's loss profit: 0, @@ -2759,6 +2765,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_EXPIRED_ACCOUNT, borrowedAmount: 1000, borrowedAmountWithInterest: 1100, + borrowedAmountWithInterestAndFees: 1110, amountToPool: 1130, // amountToPool = 1100 + 100 * 10% + 2000 * 1% = 1130 remainingFunds: 829, //remainingFunds: 2000 * (100% - 2%) - 1130 - 1 = 829 profit: 30, @@ -2770,6 +2777,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_EXPIRED_ACCOUNT, borrowedAmount: 900, borrowedAmountWithInterest: 2000, + borrowedAmountWithInterestAndFees: 2110, amountToPool: 2058, // amountToPool = 2000 + 1100 * 10% + 2100 * 1% = 2131, totalFunds = 2100 * 98% = 2058, so, amount to pool would be 2058 remainingFunds: 0, profit: 58, @@ -2781,6 +2789,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_EXPIRED_ACCOUNT, borrowedAmount: 900, borrowedAmountWithInterest: 1900, + borrowedAmountWithInterestAndFees: 2110, amountToPool: 980, // amountToPool = 1900 + 1000 * 10% + 1000 * 2% = 2020, totalFunds = 1000 * 98% = 980, So, amount to pool would be 980 remainingFunds: 0, // 0, cause it's loss profit: 0, @@ -2792,6 +2801,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_PAUSED, borrowedAmount: 1000, borrowedAmountWithInterest: 1100, + borrowedAmountWithInterestAndFees: 1110, amountToPool: 1150, // amountToPool = 1100 + 100 * 10% + 2000 * 2% = 1150 remainingFunds: 849, //remainingFunds: 2000 - 1150 - 1 = 869 profit: 50, @@ -2803,6 +2813,7 @@ contract CreditManagerTest is closureActionType: ClosureAction.LIQUIDATE_PAUSED, borrowedAmount: 900, borrowedAmountWithInterest: 1900, + borrowedAmountWithInterestAndFees: 2000, amountToPool: 1000, // amountToPool = 1900 + 1000 * 10% + 1000 * 2% = 2020, totalFunds = 1000 * 98% = 980, So, amount to pool would be 980 remainingFunds: 0, // 0, cause it's loss profit: 0, @@ -2820,7 +2831,8 @@ contract CreditManagerTest is cases[i].totalValue, cases[i].closureActionType, cases[i].borrowedAmount, - cases[i].borrowedAmountWithInterest + cases[i].borrowedAmountWithInterest, + cases[i].borrowedAmountWithInterestAndFees ); assertEq( @@ -2850,8 +2862,8 @@ contract CreditManagerTest is // TRASNFER ASSETS TO // - /// @dev [CM-44]: _transferAssetsTo sends all tokens except underlying one and not-enabled to provided address - function test_CM_44_transferAssetsTo_sends_all_tokens_except_underlying_one_to_provided_address() + /// @dev [CM-44]: _beforeAccountClosure sends all tokens except underlying one and not-enabled to provided address + function test_CM_44_beforeAccountClosure_sends_all_tokens_except_underlying_one_to_provided_address() public { // It enables CreditManagerTestInternal for some test cases @@ -2892,18 +2904,36 @@ contract CreditManagerTest is LINK_EXCHANGE_AMOUNT ); - address wethTokenAddr = tokenTestSuite.addressOf(Tokens.WETH); - creditManager.checkAndEnableToken(creditAccount, wethTokenAddr); + creditManager.checkAndEnableToken( + creditAccount, + tokenTestSuite.addressOf(Tokens.WETH) + ); + creditManager.checkAndEnableToken( + creditAccount, + tokenTestSuite.addressOf(Tokens.USDC) + ); + creditManager.checkAndEnableToken( + creditAccount, + tokenTestSuite.addressOf(Tokens.LINK) + ); uint256 enabledTokenMask = creditManager.enabledTokensMap( creditAccount ); - cmi.transferAssetsTo( + uint256 skipTokenMask = creditManager.tokenMasksMap( + tokenTestSuite.addressOf(Tokens.USDC) + ) | + creditManager.tokenMasksMap( + tokenTestSuite.addressOf(Tokens.LINK) + ); + + cmi.beforeAccountClosure( creditAccount, friend, convertToETH, - enabledTokenMask + enabledTokenMask, + skipTokenMask ); expectBalance( diff --git a/contracts/test/mocks/credit/CreditManagerTestInternal.sol b/contracts/test/mocks/credit/CreditManagerTestInternal.sol index 67981d9..7bc9a00 100644 --- a/contracts/test/mocks/credit/CreditManagerTestInternal.sol +++ b/contracts/test/mocks/credit/CreditManagerTestInternal.sol @@ -46,7 +46,8 @@ contract CreditManagerTestInternal is CreditManager { uint256 totalValue, ClosureAction closureActionType, uint256 borrowedAmount, - uint256 borrowedAmountWithInterest + uint256 borrowedAmountWithInterest, + uint256 borrowedAmountWithInterestAndFees ) external view @@ -62,17 +63,25 @@ contract CreditManagerTestInternal is CreditManager { totalValue, closureActionType, borrowedAmount, - borrowedAmountWithInterest + borrowedAmountWithInterest, + borrowedAmountWithInterestAndFees ); } - function transferAssetsTo( + function beforeAccountClosure( address creditAccount, - address to, + address remainingAssetRecipient, bool convertWETH, - uint256 enabledTokenMask + uint256 enabledTokensMask, + uint256 skipTokenMask ) external { - _transferAssetsTo(creditAccount, to, convertWETH, enabledTokenMask); + _beforeAccountClosure( + creditAccount, + remainingAssetRecipient, + convertWETH, + enabledTokensMask, + skipTokenMask + ); } function safeTokenTransfer(