diff --git a/contracts/credit/CreditConfiguratorV3.sol b/contracts/credit/CreditConfiguratorV3.sol index bececf54..fa09aaa9 100644 --- a/contracts/credit/CreditConfiguratorV3.sol +++ b/contracts/credit/CreditConfiguratorV3.sol @@ -60,9 +60,10 @@ contract CreditConfiguratorV3 is ICreditConfiguratorV3, ACLTrait, SanityCheckTra } /// @notice Constructor + /// @param _acl ACL contract address /// @param _creditManager Credit manager to connect to /// @dev Copies allowed adaprters from the currently connected configurator - constructor(address _creditManager) ACLTrait(ACLTrait(CreditManagerV3(_creditManager).pool()).acl()) { + constructor(address _acl, address _creditManager) ACLTrait(_acl) { creditManager = _creditManager; // I:[CC-1] underlying = CreditManagerV3(_creditManager).underlying(); // I:[CC-1] @@ -481,9 +482,6 @@ contract CreditConfiguratorV3 is ICreditConfiguratorV3, ACLTrait, SanityCheckTra (uint128 minDebt, uint128 maxDebt) = prevCreditFacade.debtLimits(); _setLimits({minDebt: minDebt, maxDebt: maxDebt}); // I:[CC-22] - address lossLiquidator = prevCreditFacade.lossLiquidator(); - if (lossLiquidator != address(0)) _setLossLiquidator(lossLiquidator); // I:[CC-22] - _migrateForbiddenTokens(prevCreditFacade.forbiddenTokenMask()); // I:[CC-22C] if (prevCreditFacade.expirable() && CreditFacadeV3(newCreditFacade).expirable()) { @@ -600,25 +598,22 @@ contract CreditConfiguratorV3 is ICreditConfiguratorV3, ACLTrait, SanityCheckTra emit SetMaxDebtPerBlockMultiplier(newMaxDebtLimitPerBlockMultiplier); // I:[CC-24] } - /// @notice Sets the new loss liquidator which can enforce policies on how liquidations with loss are performed - /// @param newLossLiquidator New loss liquidator, must be a contract - function setLossLiquidator(address newLossLiquidator) + /// @notice Sets the new loss policy which control which lossy liquidations should be allowed + /// @param newLossPolicy New loss policy, must be a contract + function setLossPolicy(address newLossPolicy) external override configuratorOnly // I:[CC-2] - nonZeroAddress(newLossLiquidator) // I:[CC-26] + nonZeroAddress(newLossPolicy) // I:[CC-26] { - _setLossLiquidator(newLossLiquidator); // I:[CC-26] - } + if (newLossPolicy.code.length == 0) revert AddressIsNotContractException(newLossPolicy); // I:[CC-26] - /// @dev `setLossLiquidator` implementation - function _setLossLiquidator(address newLossLiquidator) internal { CreditFacadeV3 cf = CreditFacadeV3(creditFacade()); - if (cf.lossLiquidator() == newLossLiquidator) return; + if (cf.lossPolicy() == newLossPolicy) return; - cf.setLossLiquidator(newLossLiquidator); // I:[CC-26] - emit SetLossLiquidator(newLossLiquidator); // I:[CC-26] + cf.setLossPolicy(newLossPolicy); // I:[CC-26] + emit SetLossPolicy(newLossPolicy); // I:[CC-26] } /// @notice Sets a new credit facade expiration date diff --git a/contracts/credit/CreditFacadeV3.sol b/contracts/credit/CreditFacadeV3.sol index d102fccc..ed1adba8 100644 --- a/contracts/credit/CreditFacadeV3.sol +++ b/contracts/credit/CreditFacadeV3.sol @@ -27,6 +27,7 @@ import "../interfaces/IExceptions.sol"; import {IPoolV3} from "../interfaces/IPoolV3.sol"; import {IPriceOracleV3, PriceUpdate} from "../interfaces/IPriceOracleV3.sol"; import {IDegenNFT} from "../interfaces/base/IDegenNFT.sol"; +import {ILossPolicy} from "../interfaces/base/ILossPolicy.sol"; import {IPhantomToken, IPhantomTokenWithdrawer} from "../interfaces/base/IPhantomToken.sol"; import {IWETH} from "../interfaces/external/IWETH.sol"; @@ -117,7 +118,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT uint256 public override forbiddenTokenMask; /// @notice Contract that enforces a policy on how liquidations with loss are performed - address public override lossLiquidator; + address public override lossPolicy; /// @dev Ensures that function caller is credit configurator modifier creditConfiguratorOnly() { @@ -132,12 +133,9 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT } /// @dev Ensures that function can't be called when the contract is paused, unless - /// caller is an approved emergency liquidator or the loss liquidator + /// caller is an approved emergency liquidator modifier whenNotPausedOrEmergency() { - require( - !paused() || _hasRole("EMERGENCY_LIQUIDATOR", msg.sender) || msg.sender == lossLiquidator, - "Pausable: paused" - ); + require(!paused() || _hasRole("EMERGENCY_LIQUIDATOR", msg.sender), "Pausable: paused"); _; } @@ -156,6 +154,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @notice Constructor /// @param _acl ACL contract address /// @param _creditManager Credit manager to connect this facade to + /// @param _lossPolicy Loss policy address /// @param _botList Bot list address /// @param _weth WETH token address /// @param _degenNFT Degen NFT address or `address(0)` @@ -164,12 +163,14 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT constructor( address _acl, address _creditManager, + address _lossPolicy, address _botList, address _weth, address _degenNFT, bool _expirable - ) ACLTrait(_acl) nonZeroAddress(_botList) { + ) ACLTrait(_acl) nonZeroAddress(_lossPolicy) nonZeroAddress(_botList) { creditManager = _creditManager; // U:[FA-1] + lossPolicy = _lossPolicy; // U:[FA-1] botList = _botList; // U:[FA-1] weth = _weth; // U:[FA-1] degenNFT = _degenNFT; // U:[FA-1] @@ -266,10 +267,11 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @notice Liquidates a credit account /// - Updates price feeds before running all computations if such call is present in the multicall /// - Evaluates account's collateral and debt to determine whether liquidated account is unhealthy or expired + /// - If account has bad debt, liquidation is only allowed when it doesn't violate the loss policy, + /// further borrowing through the facade is forbidden in this case /// - Performs a multicall (only `addCollateral`, `withdrawCollateral` and adapter calls are allowed) /// - Liquidates a credit account in the credit manager, which repays debt to the pool, removes quotas, and /// transfers underlying to the liquidator - /// - If pool incurs a loss on liquidation, further borrowing through the facade is forbidden /// @notice The function computes account’s total value (oracle value of enabled tokens), discounts it by liquidator’s /// premium, and uses this value to compute funds due to the pool and owner. /// Debt to the pool must be repaid in underlying, while funds due to owner might be covered by underlying @@ -282,21 +284,24 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @param creditAccount Account to liquidate /// @param to Address to transfer underlying left after liquidation /// @param calls List of calls to perform before liquidating the account - /// @return reportedLoss Loss incurred on liquidation, if any - /// @dev If liquidation incurs loss, reverts if caller is not the loss liquidator - /// @dev If facade is paused, reverts if caller is not an approved emergency liquidator or the loss liquidator + /// @param lossPolicyData Additional data to pass to the loss policy contract + /// @dev If 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 has no debt or is neither unhealthy nor expired /// @dev Reverts if remaining token balances increase during the multicall /// @dev Liquidator can fully seize non-enabled tokens so it's highly recommended to avoid holding them. /// Since adapter calls are allowed, unclaimed rewards from integrated protocols are also at risk; /// bots can be used to claim and withdraw them. - function liquidateCreditAccount(address creditAccount, address to, MultiCall[] calldata calls) - external + function liquidateCreditAccount( + address creditAccount, + address to, + MultiCall[] calldata calls, + bytes memory lossPolicyData + ) + public override whenNotPausedOrEmergency // U:[FA-2,12] nonReentrant // U:[FA-4] - returns (uint256 reportedLoss) { uint256 flags = LIQUIDATE_CREDIT_ACCOUNT_PERMISSIONS | SKIP_COLLATERAL_CHECK_FLAG; if ( @@ -308,6 +313,12 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT } (CollateralDebtData memory collateralDebtData, bool isUnhealthy) = _revertIfNotLiquidatable(creditAccount); // U:[FA-13,14] + if (isUnhealthy && _hasBadDebt(collateralDebtData)) { + if (!ILossPolicy(lossPolicy).isLiquidatable(creditAccount, msg.sender, lossPolicyData)) { + revert CreditAccountNotLiquidatableWithLossException(); // U:[FA-17] + } + maxDebtPerBlockMultiplier = 0; // U:[FA-17] + } BalanceWithMask[] memory initialBalances = BalancesLogic.storeBalances({ creditAccount: creditAccount, @@ -327,8 +338,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT collateralDebtData.enabledTokensMask = collateralDebtData.enabledTokensMask.enable(UNDERLYING_TOKEN_MASK); // U:[FA-14] - uint256 remainingFunds; - (remainingFunds, reportedLoss) = ICreditManagerV3(creditManager).liquidateCreditAccount({ + (uint256 remainingFunds,) = ICreditManagerV3(creditManager).liquidateCreditAccount({ creditAccount: creditAccount, collateralDebtData: collateralDebtData, to: to, @@ -336,14 +346,11 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT }); // U:[FA-14] emit LiquidateCreditAccount(creditAccount, msg.sender, to, remainingFunds); // U:[FA-14] + } - if (reportedLoss != 0) { - maxDebtPerBlockMultiplier = 0; // U:[FA-17] - - if (msg.sender != lossLiquidator) { - revert CallerNotLossLiquidatorException(); // U:[FA-17] - } - } + /// @dev Deprecated method that preserves liquidation signature from v3.0.x by using empty loss policy data + function liquidateCreditAccount(address creditAccount, address to, MultiCall[] calldata calls) external override { + liquidateCreditAccount(creditAccount, to, calls, ""); } /// @notice Partially liquidates credit account's debt in exchange for discounted collateral @@ -361,7 +368,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @param to Account to withdraw seized `token` to /// @param priceUpdates On-demand price feed updates to apply before calculations, see `PriceUpdate` for details /// @return seizedAmount Amount of `token` seized - /// @dev If facade is paused, reverts if caller is not an approved emergency liquidator or the loss liquidator + /// @dev If 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 has no debt or is neither unhealthy nor expired /// @dev Reverts if `token` is underlying or if `token` is a phantom token and its `depositedToken` is underlying @@ -847,19 +854,15 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT totalBorrowedInBlock = type(uint128).max; // U:[FA-49] } - /// @notice Sets the new loss liquidator - /// @param newLossLiquidator New loss liquidator + /// @notice Sets the new loss policy + /// @param newLossPolicy New loss policy /// @dev Reverts if caller is not credit configurator - /// @dev Reverts if `newLossLiquidator` is not a contract - function setLossLiquidator(address newLossLiquidator) + function setLossPolicy(address newLossPolicy) external override creditConfiguratorOnly // U:[FA-6] { - if (newLossLiquidator.code.length == 0) { - revert AddressIsNotContractException(newLossLiquidator); // U:[FA-51] - } - lossLiquidator = newLossLiquidator; // U:[FA-51] + lossPolicy = newLossPolicy; // U:[FA-51] } /// @notice Changes token's forbidden status @@ -880,7 +883,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @notice Pauses contract, can only be called by an account with pausable admin role /// @dev Pause blocks all user entrypoints to the contract. - /// Liquidations remain open only to emergency and loss liquidators. + /// Liquidations remain open only to emergency liquidators. /// @dev Reverts if contract is already paused function pause() external override pausableAdminsOnly { _pause(); @@ -957,6 +960,14 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT if (cdd.debt == 0 || !isUnhealthy && !_isExpired()) revert CreditAccountNotLiquidatableException(); // U:[FA-13] } + /// @dev Whether account's total value (minus liquidator's premium) is below its outstanding debt + function _hasBadDebt(CollateralDebtData memory cdd) internal view returns (bool) { + (,, uint16 liquidationDiscount,,) = ICreditManagerV3(creditManager).fees(); + // NOTE: this formula does not account for transfer fees for simplicity, so there might be edge + // cases when liquidation bypasses the loss policy, however loss size is bounded by the fee + return cdd.totalValue * liquidationDiscount < (cdd.debt + cdd.accruedInterest) * PERCENTAGE_FACTOR; + } + /// @dev Calculates and returns partial liquidation payment amounts: /// - amount of underlying that should go towards repaying debt /// - amount of underlying that should go towards liquidation fees diff --git a/contracts/interfaces/ICreditConfiguratorV3.sol b/contracts/interfaces/ICreditConfiguratorV3.sol index a6ebc736..72e269b8 100644 --- a/contracts/interfaces/ICreditConfiguratorV3.sol +++ b/contracts/interfaces/ICreditConfiguratorV3.sol @@ -79,8 +79,8 @@ interface ICreditConfiguratorV3Events { /// @notice Emitted when a new max debt per block multiplier is set event SetMaxDebtPerBlockMultiplier(uint8 maxDebtPerBlockMultiplier); - /// @notice Emitted when new loss liquidator is set - event SetLossLiquidator(address indexed liquidator); + /// @notice Emitted when new loss policy is set + event SetLossPolicy(address indexed lossPolicy); /// @notice Emitted when a new expiration timestamp is set in the credit facade event SetExpirationDate(uint40 expirationDate); @@ -154,7 +154,7 @@ interface ICreditConfiguratorV3 is IVersion, IACLTrait, ICreditConfiguratorV3Eve function forbidBorrowing() external; - function setLossLiquidator(address newLossLiquidator) external; + function setLossPolicy(address newLossPolicy) external; function setExpirationDate(uint40 newExpirationDate) external; } diff --git a/contracts/interfaces/ICreditFacadeV3.sol b/contracts/interfaces/ICreditFacadeV3.sol index bc418eda..8c4e1c83 100644 --- a/contracts/interfaces/ICreditFacadeV3.sol +++ b/contracts/interfaces/ICreditFacadeV3.sol @@ -101,7 +101,7 @@ interface ICreditFacadeV3 is IVersion, IACLTrait, ICreditFacadeV3Events { function debtLimits() external view returns (uint128 minDebt, uint128 maxDebt); - function lossLiquidator() external view returns (address); + function lossPolicy() external view returns (address); function forbiddenTokenMask() external view returns (uint256); @@ -116,9 +116,14 @@ interface ICreditFacadeV3 is IVersion, IACLTrait, ICreditFacadeV3Events { function closeCreditAccount(address creditAccount, MultiCall[] calldata calls) external payable; - function liquidateCreditAccount(address creditAccount, address to, MultiCall[] calldata calls) - external - returns (uint256 reportedLoss); + function liquidateCreditAccount( + address creditAccount, + address to, + MultiCall[] calldata calls, + bytes memory lossPolicyData + ) external; + + function liquidateCreditAccount(address creditAccount, address to, MultiCall[] calldata calls) external; function partiallyLiquidateCreditAccount( address creditAccount, @@ -141,7 +146,7 @@ interface ICreditFacadeV3 is IVersion, IACLTrait, ICreditFacadeV3Events { function setDebtLimits(uint128 newMinDebt, uint128 newMaxDebt, uint8 newMaxDebtPerBlockMultiplier) external; - function setLossLiquidator(address newLossLiquidator) external; + function setLossPolicy(address newLossPolicy) external; function setTokenAllowance(address token, AllowanceAction allowance) external; diff --git a/contracts/interfaces/IExceptions.sol b/contracts/interfaces/IExceptions.sol index 23c25a71..5d654c63 100644 --- a/contracts/interfaces/IExceptions.sol +++ b/contracts/interfaces/IExceptions.sol @@ -181,6 +181,9 @@ error UnknownMethodException(bytes4 selector); /// @notice Thrown if a liquidator tries to liquidate an account with a health factor above 1 error CreditAccountNotLiquidatableException(); +/// @notice Thrown if a liquidator tries to liquidate an account with loss but violates the loss policy +error CreditAccountNotLiquidatableWithLossException(); + /// @notice Thrown if too much new debt was taken within a single block error BorrowedBlockLimitException(); @@ -278,9 +281,6 @@ error CallerNotExecutorException(); /// @notice Thrown on attempting to call an access restricted function not as veto admin error CallerNotVetoAdminException(); -/// @notice Thrown on attempting to perform liquidation with loss not through the loss liquidator contract -error CallerNotLossLiquidatorException(); - // -------- // // BOT LIST // // -------- // diff --git a/contracts/interfaces/base/ILossPolicy.sol b/contracts/interfaces/base/ILossPolicy.sol new file mode 100644 index 00000000..7a052f64 --- /dev/null +++ b/contracts/interfaces/base/ILossPolicy.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {IVersion} from "./IVersion.sol"; + +/// @notice Loss policy dictates the conditions under which a liquidation with bad debt can proceed. +/// For example, it can restrict such liquidations to only be performed by whitelisted accounts that +/// can return premium to the DAO to recover part of the losses, or prevent liquidations of an asset +/// whose market price drops for a short period of time while its fundamental value doesn't change. +interface ILossPolicy is IVersion { + /// @notice Whether `creditAccount` can be liquidated with loss by `caller`, `data` is an optional field + /// that can be used to pass some off-chain data specific to the loss policy implementation + function isLiquidatable(address creditAccount, address caller, bytes calldata data) external returns (bool); + + /// @notice Emergency function which forces `isLiquidatable` to always return `false` + function disable() external; + + /// @notice Emergency function which forces `isLiquidatable` to always return `true` + function enable() external; +} diff --git a/contracts/test/helpers/IntegrationTestHelper.sol b/contracts/test/helpers/IntegrationTestHelper.sol index be3777ed..c119a913 100644 --- a/contracts/test/helpers/IntegrationTestHelper.sol +++ b/contracts/test/helpers/IntegrationTestHelper.sol @@ -23,6 +23,7 @@ import {GaugeV3} from "../../pool/GaugeV3.sol"; import {GearStakingV3} from "../../core/GearStakingV3.sol"; import {CreditManagerFactory} from "../suites/CreditManagerFactory.sol"; +import {ILossPolicy} from "../../interfaces/base/ILossPolicy.sol"; import {ICreditFacadeV3Multicall} from "../../interfaces/ICreditFacadeV3.sol"; import {CreditManagerV3} from "../../credit/CreditManagerV3.sol"; @@ -66,6 +67,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { IContractsRegister cr; AccountFactoryV3 accountFactory; IPriceOracleV3 priceOracle; + ILossPolicy lossPolicy; BotListV3 botList; GearStakingV3 gearStaking; @@ -233,6 +235,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { acl = IACL(address(gp.acl())); priceOracle = gp.priceOracle(); + lossPolicy = gp.lossPolicy(); accountFactory = gp.accountFactory(); botList = gp.botList(); cr = gp.contractsRegister(); @@ -273,6 +276,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { priceOracle = IPriceOracleV3(CreditManagerV3(cm).priceOracle()); address cf = CreditManagerV3(cm).creditFacade(); + lossPolicy = ILossPolicy(CreditFacadeV3(cf).lossPolicy()); botList = BotListV3(payable(CreditFacadeV3(cf).botList())); @@ -424,6 +428,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { }); CreditManagerFactory.FacadeParams memory facadeParams = CreditManagerFactory.FacadeParams({ weth: weth, + lossPolicy: address(lossPolicy), botList: address(botList), degenNFT: (whitelisted) ? address(degenNFT) : address(0), expirable: (anyExpirable) ? cmParams.expirable : expirable @@ -437,9 +442,6 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { vm.startPrank(CONFIGURATOR); creditConfigurator.setDebtLimits(cmParams.minDebt, cmParams.maxDebt); - vm.etch(LIQUIDATOR, "DUMMY_CODE"); - creditConfigurator.setLossLiquidator(LIQUIDATOR); - vm.stopPrank(); vm.roll(block.number + 1); diff --git a/contracts/test/integration/credit/CreditConfigurator.int.t.sol b/contracts/test/integration/credit/CreditConfigurator.int.t.sol index 50b2b374..7e2d4fc6 100644 --- a/contracts/test/integration/credit/CreditConfigurator.int.t.sol +++ b/contracts/test/integration/credit/CreditConfigurator.int.t.sol @@ -26,7 +26,8 @@ import "../../../interfaces/IExceptions.sol"; import "../../lib/constants.sol"; // MOCKS -import {AdapterMock} from "../../mocks//core/AdapterMock.sol"; +import {AdapterMock} from "../../mocks/core/AdapterMock.sol"; +import {LossPolicyMock} from "../../mocks/core/LossPolicyMock.sol"; import {TargetContractMock} from "../../mocks/core/TargetContractMock.sol"; import {CreditFacadeV3Harness} from "../../unit/credit/CreditFacadeV3Harness.sol"; import {IntegrationTestHelper} from "../../helpers/IntegrationTestHelper.sol"; @@ -235,7 +236,7 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf creditConfigurator.setMaxDebtPerBlockMultiplier(0); vm.expectRevert(CallerNotConfiguratorException.selector); - creditConfigurator.setLossLiquidator(address(0)); + creditConfigurator.setLossPolicy(address(0)); vm.expectRevert(CallerNotConfiguratorException.selector); creditConfigurator.setExpirationDate(0); @@ -797,7 +798,13 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf if (expirable) { CreditFacadeV3 initialCf = new CreditFacadeV3( - address(acl), address(creditManager), address(botList), address(0), address(0), true + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + true ); vm.prank(CONFIGURATOR); @@ -809,16 +816,17 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf creditFacade = initialCf; } - address lossLiquidator = makeAddr("LOSS_LIQUIDATOR"); - vm.etch(lossLiquidator, "DUMMY_CODE"); - vm.prank(CONFIGURATOR); - creditConfigurator.setLossLiquidator(lossLiquidator); - vm.prank(CONFIGURATOR); creditConfigurator.setMaxDebtPerBlockMultiplier(DEFAULT_LIMIT_PER_BLOCK_MULTIPLIER + 1); CreditFacadeV3 cf = new CreditFacadeV3( - address(acl), address(creditManager), address(botList), address(0), address(0), expirable + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + expirable ); uint8 maxDebtPerBlockMultiplier = creditFacade.maxDebtPerBlockMultiplier(); @@ -841,8 +849,6 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf (uint128 minDebt2, uint128 maxDebt2) = cf.debtLimits(); - address lossLiquidator2 = cf.lossLiquidator(); - assertEq( maxDebtPerBlockMultiplier2, migrateSettings ? maxDebtPerBlockMultiplier : DEFAULT_LIMIT_PER_BLOCK_MULTIPLIER, @@ -853,8 +859,6 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf assertEq(expirationDate2, migrateSettings ? expirationDate : 0, "Incorrect expirationDate"); - assertEq(lossLiquidator2, migrateSettings ? lossLiquidator : address(0), "Incorrect lossLiquidator"); - vm.revertTo(snapshot); } } @@ -863,8 +867,9 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf function test_I_CC_22B_setCreditFacade_reverts_if_new_facade_is_adapter() public creditTest { vm.startPrank(CONFIGURATOR); - CreditFacadeV3 cf = - new CreditFacadeV3(address(acl), address(creditManager), address(botList), address(0), address(0), false); + CreditFacadeV3 cf = new CreditFacadeV3( + address(acl), address(creditManager), address(lossPolicy), address(botList), address(0), address(0), false + ); AdapterMock adapter = new AdapterMock(address(creditManager), address(cf)); TargetContractMock target = new TargetContractMock(); @@ -902,7 +907,13 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf vm.stopPrank(); CreditFacadeV3 cf = new CreditFacadeV3( - address(acl), address(creditManager), address(botList), address(0), address(0), false + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + false ); vm.prank(CONFIGURATOR); @@ -930,12 +941,12 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf vm.prank(CONFIGURATOR); creditConfigurator.allowAdapter(address(adapter1)); - CreditConfiguratorV3 cc1 = new CreditConfiguratorV3(address(creditManager)); + CreditConfiguratorV3 cc1 = new CreditConfiguratorV3(address(acl), address(creditManager)); vm.prank(CONFIGURATOR); creditConfigurator.allowAdapter(address(adapter2)); - CreditConfiguratorV3 cc2 = new CreditConfiguratorV3(address(creditManager)); + CreditConfiguratorV3 cc2 = new CreditConfiguratorV3(address(acl), address(creditManager)); vm.expectRevert(IncorrectAdaptersSetException.selector); vm.prank(CONFIGURATOR); @@ -1005,22 +1016,25 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf assertEq(creditFacade.expirationDate(), expectedExpirationDate, "Incorrect new expirationDate"); } - /// @dev I:[CC-26]: setLossLiquidator works correctly - function test_I_CC_26_setLossLiquidator_works_correctly() public creditTest { + /// @dev I:[CC-26]: setLossPolicy works correctly + function test_I_CC_26_setLossPolicy_works_correctly() public creditTest { vm.expectRevert(ZeroAddressException.selector); vm.prank(CONFIGURATOR); - creditConfigurator.setLossLiquidator(address(0)); + creditConfigurator.setLossPolicy(address(0)); + + vm.expectRevert(abi.encodeWithSelector(AddressIsNotContractException.selector, address(0xdead))); + vm.prank(CONFIGURATOR); + creditConfigurator.setLossPolicy(address(0xdead)); - address liquidator = makeAddr("LOSS_LIQUIDATOR"); - vm.etch(liquidator, "DUMMY_CODE"); + address lossPolicy = address(new LossPolicyMock()); vm.expectEmit(true, true, true, true); - emit SetLossLiquidator(liquidator); + emit SetLossPolicy(lossPolicy); vm.prank(CONFIGURATOR); - creditConfigurator.setLossLiquidator(liquidator); + creditConfigurator.setLossPolicy(lossPolicy); - assertEq(creditFacade.lossLiquidator(), liquidator, "Loss liquidator not set"); + assertEq(creditFacade.lossPolicy(), lossPolicy, "Loss policy not set"); } /// @dev I:[CC-29]: Array-based parameters are migrated correctly to new CC @@ -1028,7 +1042,7 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf vm.prank(CONFIGURATOR); creditConfigurator.allowAdapter(address(adapterMock)); - CreditConfiguratorV3 newConfigurator = new CreditConfiguratorV3(address(creditManager)); + CreditConfiguratorV3 newConfigurator = new CreditConfiguratorV3(address(acl), address(creditManager)); address[] memory newAllowedAdapters = newConfigurator.allowedAdapters(); diff --git a/contracts/test/mocks/core/LossPolicyMock.sol b/contracts/test/mocks/core/LossPolicyMock.sol new file mode 100644 index 00000000..479b2ca3 --- /dev/null +++ b/contracts/test/mocks/core/LossPolicyMock.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {ILossPolicy} from "../../../interfaces/base/ILossPolicy.sol"; + +contract LossPolicyMock is ILossPolicy { + uint256 public constant override version = 3_10; + bytes32 public constant override contractType = "LOSS_POLICY_MOCK"; + + bool public enabled = true; + + function isLiquidatable(address, address, bytes calldata) external view override returns (bool) { + return enabled; + } + + function enable() external override { + enabled = true; + } + + function disable() external override { + enabled = false; + } +} diff --git a/contracts/test/suites/CreditManagerFactory.sol b/contracts/test/suites/CreditManagerFactory.sol index 014c8d64..e2667317 100644 --- a/contracts/test/suites/CreditManagerFactory.sol +++ b/contracts/test/suites/CreditManagerFactory.sol @@ -6,7 +6,7 @@ pragma solidity ^0.8.17; import "@openzeppelin/contracts/utils/Create2.sol"; import "../interfaces/IAddressProviderV3.sol"; -import {IPoolV3} from "../../interfaces/IPoolV3.sol"; +import {IACLTrait} from "../../interfaces/base/IACLTrait.sol"; import {CreditManagerV3} from "../../credit/CreditManagerV3.sol"; import {CreditFacadeV3} from "../../credit/CreditFacadeV3.sol"; @@ -35,6 +35,7 @@ contract CreditManagerFactory { } struct FacadeParams { + address lossPolicy; address botList; address weth; address degenNFT; @@ -55,9 +56,11 @@ contract CreditManagerFactory { cmParams.name ); + address acl = IACLTrait(pool).acl(); creditFacade = new CreditFacadeV3( - IPoolV3(pool).acl(), + acl, address(creditManager), + cfParams.lossPolicy, cfParams.botList, cfParams.weth, cfParams.degenNFT, @@ -65,7 +68,7 @@ contract CreditManagerFactory { ); creditManager.setCreditFacade(address(creditFacade)); - creditConfigurator = new CreditConfiguratorV3(address(creditManager)); + creditConfigurator = new CreditConfiguratorV3(acl, address(creditManager)); creditManager.setCreditConfigurator(address(creditConfigurator)); } } diff --git a/contracts/test/suites/GenesisFactory.sol b/contracts/test/suites/GenesisFactory.sol index 6628db93..af18b188 100644 --- a/contracts/test/suites/GenesisFactory.sol +++ b/contracts/test/suites/GenesisFactory.sol @@ -7,6 +7,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {AddressProviderV3ACLMock} from "../mocks/core/AddressProviderV3ACLMock.sol"; +import {LossPolicyMock} from "../mocks/core/LossPolicyMock.sol"; import {AccountFactoryV3} from "../../core/AccountFactoryV3.sol"; import {GearStakingV3} from "../../core/GearStakingV3.sol"; import {BotListV3} from "../../core/BotListV3.sol"; @@ -21,6 +22,7 @@ import {PriceOracleV3} from "../../core/PriceOracleV3.sol"; contract GenesisFactory is Ownable { AddressProviderV3ACLMock public acl; PriceOracleV3 public priceOracle; + LossPolicyMock public lossPolicy; BotListV3 public botList; AccountFactoryV3 public accountFactory; IContractsRegister public contractsRegister; @@ -31,6 +33,7 @@ contract GenesisFactory is Ownable { contractsRegister = IContractsRegister(address(acl)); priceOracle = new PriceOracleV3(address(acl)); + lossPolicy = new LossPolicyMock(); accountFactory = new AccountFactoryV3(msg.sender); botList = new BotListV3(msg.sender); diff --git a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol index f2ade205..8e5d3ddf 100644 --- a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol @@ -21,6 +21,7 @@ import {CreditManagerMock} from "../../mocks/credit/CreditManagerMock.sol"; import {DegenNFTMock} from "../../mocks/token/DegenNFTMock.sol"; import {AdapterMock} from "../../mocks/core/AdapterMock.sol"; import {BotListMock} from "../../mocks/core/BotListMock.sol"; +import {LossPolicyMock} from "../../mocks/core/LossPolicyMock.sol"; import {PriceOracleMock} from "../../mocks/oracles/PriceOracleMock.sol"; import {UpdatablePriceFeedMock} from "../../mocks/oracles/UpdatablePriceFeedMock.sol"; import {AdapterCallMock} from "../../mocks/core/AdapterCallMock.sol"; @@ -75,6 +76,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve CreditFacadeV3Harness creditFacade; CreditManagerMock creditManagerMock; PriceOracleMock priceOracleMock; + LossPolicyMock lossPolicyMock; BotListMock botListMock; DegenNFTMock degenNFTMock; @@ -145,6 +147,8 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditManagerMock = new CreditManagerMock({_addressProvider: address(addressProvider), _pool: address(poolMock)}); + + lossPolicyMock = new LossPolicyMock(); } function _withoutDegenNFT() internal { @@ -170,6 +174,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditFacade = new CreditFacadeV3Harness( address(addressProvider), address(creditManagerMock), + address(lossPolicyMock), address(botListMock), tokenTestSuite.addressOf(TOKEN_WETH), address(degenNFTMock), @@ -185,6 +190,9 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve assertEq(creditFacade.underlying(), tokenTestSuite.addressOf(TOKEN_DAI), "Incorrect underlying"); assertEq(creditFacade.treasury(), treasury, "Incorrect treasury"); + assertEq(creditFacade.lossPolicy(), address(lossPolicyMock), "Incorrect lossPolicy"); + assertEq(creditFacade.botList(), address(botListMock), "Incorrect botList"); + assertEq(creditFacade.weth(), tokenTestSuite.addressOf(TOKEN_WETH), "Incorrect weth token"); assertEq(creditFacade.degenNFT(), address(degenNFTMock), "Incorrect degen NFT"); @@ -194,6 +202,18 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve address(addressProvider), address(creditManagerMock), address(0), + address(botListMock), + address(0), + address(degenNFTMock), + expirable + ); + + vm.expectRevert(ZeroAddressException.selector); + new CreditFacadeV3Harness( + address(addressProvider), + address(creditManagerMock), + address(lossPolicyMock), + address(0), address(0), address(degenNFTMock), expirable @@ -304,7 +324,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditFacade.setDebtLimits(0, 0, 0); vm.expectRevert(CallerNotConfiguratorException.selector); - creditFacade.setLossLiquidator(address(0)); + creditFacade.setLossPolicy(address(0)); vm.expectRevert(CallerNotConfiguratorException.selector); creditFacade.setTokenAllowance(address(0), AllowanceAction.ALLOW); @@ -614,7 +634,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve emit LiquidateCreditAccount(creditAccount, LIQUIDATOR, FRIEND, 123); vm.prank(LIQUIDATOR); - uint256 loss = creditFacade.liquidateCreditAccount({ + creditFacade.liquidateCreditAccount({ creditAccount: creditAccount, to: FRIEND, calls: MultiCallBuilder.build( @@ -624,7 +644,6 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve ) ) }); - assertEq(loss, 0, "Non-zero loss"); } /// @dev U:[FA-14A]: liquidateCreditAccount reverts if non-underlying balance increases in multicall @@ -650,6 +669,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve CollateralDebtData memory collateralDebtData; collateralDebtData.debt = 101; + collateralDebtData.totalValue = 110; collateralDebtData.totalDebtUSD = 101; collateralDebtData.twvUSD = 100; collateralDebtData.enabledTokensMask = UNDERLYING_TOKEN_MASK | linkMask; @@ -941,22 +961,16 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve collateralDebtData.twvUSD = 100; creditManagerMock.setDebtAndCollateralData(collateralDebtData); - creditManagerMock.setLiquidateCreditAccountReturns(0, 100); - - // only the loss liquidator can call - vm.expectRevert(CallerNotLossLiquidatorException.selector); + // reverts if loss policy is violated + lossPolicyMock.disable(); + vm.expectRevert(CreditAccountNotLiquidatableWithLossException.selector); vm.prank(FRIEND); creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); - vm.etch(LIQUIDATOR, "CODE"); - vm.prank(CONFIGURATOR); - creditFacade.setLossLiquidator(LIQUIDATOR); - - // loss forbids borrowing + // if loss policy is not violated, further borrowing is forbidden + lossPolicyMock.enable(); vm.prank(LIQUIDATOR); - uint256 loss = - creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); - assertEq(loss, 100, "Incorrect loss"); + creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); assertEq(creditFacade.maxDebtPerBlockMultiplier(), 0, "Borrowing not forbidden"); } @@ -2223,19 +2237,15 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve assertEq(creditFacade.totalBorrowedInBlockInt(), type(uint128).max, "incorrect totalBorrowedInBlock"); } - /// @dev U:[FA-51]: `setLossLiquidator` works properly - function test_U_FA_51_setLossLiquidator_works_properly() public notExpirableCase { - assertEq(creditFacade.lossLiquidator(), address(0), "SETUP: incorrect loss liquidator"); - - vm.expectRevert(abi.encodeWithSelector(AddressIsNotContractException.selector, DUMB_ADDRESS)); - vm.prank(CONFIGURATOR); - creditFacade.setLossLiquidator(DUMB_ADDRESS); + /// @dev U:[FA-51]: `setLossPolicy` works properly + function test_U_FA_51_setLossPolicy_works_properly() public notExpirableCase { + assertEq(creditFacade.lossPolicy(), address(lossPolicyMock), "SETUP: incorrect loss policy"); - address liquidator = address(new GeneralMock()); + address lossPolicy = address(new LossPolicyMock()); vm.prank(CONFIGURATOR); - creditFacade.setLossLiquidator(liquidator); + creditFacade.setLossPolicy(lossPolicy); - assertEq(creditFacade.lossLiquidator(), liquidator, "Loss liquidator not set"); + assertEq(creditFacade.lossPolicy(), lossPolicy, "Loss policy not set"); } /// @dev U:[FA-52]: setTokenAllowance works properly diff --git a/contracts/test/unit/credit/CreditFacadeV3Harness.sol b/contracts/test/unit/credit/CreditFacadeV3Harness.sol index c98a32cd..9f924418 100644 --- a/contracts/test/unit/credit/CreditFacadeV3Harness.sol +++ b/contracts/test/unit/credit/CreditFacadeV3Harness.sol @@ -12,11 +12,12 @@ contract CreditFacadeV3Harness is CreditFacadeV3 { constructor( address _acl, address _creditManager, + address _lossPolicy, address _botList, address _weth, address _degenNFT, bool _expirable - ) CreditFacadeV3(_acl, _creditManager, _botList, _weth, _degenNFT, _expirable) {} + ) CreditFacadeV3(_acl, _creditManager, _lossPolicy, _botList, _weth, _degenNFT, _expirable) {} function setReentrancy(uint8 _status) external { _reentrancyStatus = _status;