From 0a0f2b9c48d044f4ae7ea3b3070c29e1297e570d Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Thu, 13 Feb 2025 12:09:26 +0200 Subject: [PATCH 1/3] feat: updatable price feeds now live at price feed store --- contracts/core/PriceOracleV3.sol | 37 +------------- contracts/credit/CreditFacadeV3.sol | 34 +++++++------ contracts/interfaces/ICreditFacadeV3.sol | 4 +- .../interfaces/ICreditFacadeV3Multicall.sol | 2 +- contracts/interfaces/IPriceOracleV3.sol | 21 -------- contracts/interfaces/base/IPriceFeedStore.sol | 16 ++++++ contracts/libraries/Constants.sol | 1 + .../test/helpers/IntegrationTestHelper.sol | 6 ++- .../credit/CreditConfigurator.int.t.sol | 26 ++++++++-- .../credit/LiquidateCreditAccount.int.t.sol | 4 +- .../test/interfaces/IAddressProviderV3.sol | 1 + .../mocks/core/AddressProviderV3ACLMock.sol | 4 ++ .../test/mocks/oracles/PriceFeedStoreMock.sol | 28 +++++++++++ .../test/mocks/oracles/PriceOracleMock.sol | 4 -- .../test/suites/CreditManagerFactory.sol | 3 +- contracts/test/suites/GenesisFactory.sol | 3 ++ .../test/unit/core/PriceOracleV3.unit.t.sol | 50 ++----------------- .../test/unit/core/PriceOracleV3Harness.sol | 4 -- .../unit/credit/CreditFacadeV3.unit.t.sol | 47 ++++++++++------- .../unit/credit/CreditFacadeV3Harness.sol | 7 +-- contracts/traits/PriceFeedValidationTrait.sol | 16 ------ 21 files changed, 146 insertions(+), 172 deletions(-) create mode 100644 contracts/interfaces/base/IPriceFeedStore.sol create mode 100644 contracts/test/mocks/oracles/PriceFeedStoreMock.sol diff --git a/contracts/core/PriceOracleV3.sol b/contracts/core/PriceOracleV3.sol index a2bcfd88..8a238b35 100644 --- a/contracts/core/PriceOracleV3.sol +++ b/contracts/core/PriceOracleV3.sol @@ -14,7 +14,7 @@ import { PriceFeedDoesNotExistException, PriceFeedIsNotUpdatableException } from "../interfaces/IExceptions.sol"; -import {IPriceOracleV3, PriceFeedParams, PriceUpdate} from "../interfaces/IPriceOracleV3.sol"; +import {IPriceOracleV3, PriceFeedParams} from "../interfaces/IPriceOracleV3.sol"; import {IUpdatablePriceFeed} from "../interfaces/base/IPriceFeed.sol"; import {ACLTrait} from "../traits/ACLTrait.sol"; @@ -32,8 +32,6 @@ import {SanityCheckTrait} from "../traits/SanityCheckTrait.sol"; /// The primary purpose of reserve price feeds is to upper-bound main ones during the collateral check after /// operations that allow users to offload mispriced tokens on Gearbox and withdraw underlying; they should /// not be used for general collateral evaluation, including decisions on whether accounts are liquidatable. -/// - Finally, this contract serves as register for updatable price feeds and can be used to apply batched -/// on-demand price updates while ensuring that those are not calls to arbitrary contracts. contract PriceOracleV3 is ACLTrait, PriceFeedValidationTrait, SanityCheckTrait, IPriceOracleV3 { using EnumerableSet for EnumerableSet.AddressSet; @@ -49,9 +47,6 @@ contract PriceOracleV3 is ACLTrait, PriceFeedValidationTrait, SanityCheckTrait, /// @dev Set of all tokens that have price feeds EnumerableSet.AddressSet internal _tokensSet; - /// @dev Set of all updatable price feeds - EnumerableSet.AddressSet internal _updatablePriceFeedsSet; - /// @notice Constructor /// @param _acl ACL contract address constructor(address _acl) ACLTrait(_acl) {} @@ -125,27 +120,6 @@ contract PriceOracleV3 is ACLTrait, PriceFeedValidationTrait, SanityCheckTrait, return amount * price / scale; } - // ------------- // - // PRICE UPDATES // - // ------------- // - - /// @notice Returns all updatable price feeds - function getUpdatablePriceFeeds() external view override returns (address[] memory) { - return _updatablePriceFeedsSet.values(); - } - - /// @notice Applies on-demand price feed updates, see `PriceUpdate` for details - /// @custom:tests U:[PO-5] - function updatePrices(PriceUpdate[] calldata updates) external override { - unchecked { - uint256 len = updates.length; - for (uint256 i; i < len; ++i) { - if (!_updatablePriceFeedsSet.contains(updates[i].priceFeed)) revert PriceFeedIsNotUpdatableException(); - IUpdatablePriceFeed(updates[i].priceFeed).updatePrice(updates[i].data); - } - } - } - // ------------- // // CONFIGURATION // // ------------- // @@ -204,15 +178,6 @@ contract PriceOracleV3 is ACLTrait, PriceFeedValidationTrait, SanityCheckTrait, emit SetReservePriceFeed(token, priceFeed, stalenessPeriod, skipCheck); } - /// @notice Adds `priceFeed` to the set of updatable price feeds - /// @dev Price feed must be updatable but is not required to satisfy all validity conditions, - /// e.g., decimals need not to be equal to 8 - /// @custom:tests U:[PO-5] - function addUpdatablePriceFeed(address priceFeed) external override nonZeroAddress(priceFeed) configuratorOnly { - if (!_isUpdatable(priceFeed)) revert PriceFeedIsNotUpdatableException(); - if (_updatablePriceFeedsSet.add(priceFeed)) emit AddUpdatablePriceFeed(priceFeed); - } - // --------- // // INTERNALS // // --------- // diff --git a/contracts/credit/CreditFacadeV3.sol b/contracts/credit/CreditFacadeV3.sol index a7f6f1d0..38da83b4 100644 --- a/contracts/credit/CreditFacadeV3.sol +++ b/contracts/credit/CreditFacadeV3.sol @@ -25,18 +25,22 @@ import { } from "../interfaces/ICreditManagerV3.sol"; import "../interfaces/IExceptions.sol"; import {IPoolV3} from "../interfaces/IPoolV3.sol"; -import {IPriceOracleV3, PriceUpdate} from "../interfaces/IPriceOracleV3.sol"; +import {IPriceOracleV3} from "../interfaces/IPriceOracleV3.sol"; +import {IAddressProvider} from "../interfaces/base/IAddressProvider.sol"; import {IDegenNFT} from "../interfaces/base/IDegenNFT.sol"; import {ILossPolicy} from "../interfaces/base/ILossPolicy.sol"; import {IPhantomToken, IPhantomTokenWithdrawer} from "../interfaces/base/IPhantomToken.sol"; +import {IPriceFeedStore, PriceUpdate} from "../interfaces/base/IPriceFeedStore.sol"; import {IWETH} from "../interfaces/external/IWETH.sol"; // LIBRARIES import {Balance, BalanceDelta, BalanceWithMask, BalancesLogic, Comparison} from "../libraries/BalancesLogic.sol"; import {BitMask} from "../libraries/BitMask.sol"; import { + AP_PRICE_FEED_STORE, BOT_PERMISSIONS_SET_FLAG, INACTIVE_CREDIT_ACCOUNT_ADDRESS, + NO_VERSION_CONTROL, PERCENTAGE_FACTOR, UNDERLYING_TOKEN_MASK, DEFAULT_LIMIT_PER_BLOCK_MULTIPLIER @@ -89,6 +93,9 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @notice Pool's treasury to pay fees to address public immutable override treasury; + /// @notice Price feed store to update price feeds on-demand + address public immutable override priceFeedStore; + /// @notice Whether credit facade is expirable bool public immutable override expirable; @@ -154,6 +161,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT } /// @notice Constructor + /// @param _addressProvider Address provider contract address /// @param _creditManager Credit manager to connect this facade to /// @param _lossPolicy Loss policy address /// @param _botList Bot list address @@ -162,6 +170,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// @param _expirable Whether this facade should be expirable. If `true`, the expiration date remains unset, /// and facade never expires, until the date is set via `setExpirationDate` in the configurator. constructor( + address _addressProvider, address _creditManager, address _lossPolicy, address _botList, @@ -178,6 +187,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT underlying = ICreditManagerV3(_creditManager).underlying(); // U:[FA-1] treasury = ICreditManagerV3(_creditManager).getTreasury(); // U:[FA-1] + priceFeedStore = IAddressProvider(_addressProvider).getAddressOrRevert(AP_PRICE_FEED_STORE, NO_VERSION_CONTROL); // U:[FA-1] } // ------------------ // @@ -390,8 +400,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT nonReentrant // U:[FA-4] returns (uint256 seizedAmount) { - address priceOracle = _priceOracle(); - if (priceUpdates.length != 0) _updatePrices(priceOracle, priceUpdates); + if (priceUpdates.length != 0) _updatePrices(priceUpdates); (CollateralDebtData memory cdd, bool isUnhealthy) = _revertIfNotLiquidatable(creditAccount); // U:[FA-13,16] @@ -400,8 +409,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT repaidAmount = IERC20(underlying).safeBalanceOf(creditAccount) - balanceBefore; uint256 feeAmount; - (repaidAmount, feeAmount, seizedAmount) = - _calcPartialLiquidationPayments(repaidAmount, token, priceOracle, !isUnhealthy); // U:[FA-15] + (repaidAmount, feeAmount, seizedAmount) = _calcPartialLiquidationPayments(repaidAmount, token, !isUnhealthy); // U:[FA-15] uint256 flags; (token, seizedAmount, flags) = _tryWithdrawPhantomToken(creditAccount, token, seizedAmount, 0); // U:[FA-16A] @@ -658,7 +666,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT function _onDemandPriceUpdates(bytes calldata callData) internal { PriceUpdate[] memory updates = abi.decode(callData, (PriceUpdate[])); // U:[FA-25] - _updatePrices(_priceOracle(), updates); // U:[FA-25] + _updatePrices(updates); // U:[FA-25] } /// @dev `ICreditFacadeV3Multicall.addCollateral` implementation @@ -972,11 +980,12 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT /// - amount of underlying that should go towards repaying debt /// - amount of underlying that should go towards liquidation fees /// - amount of collateral that should be sent to the liquidator - function _calcPartialLiquidationPayments(uint256 amount, address token, address priceOracle, bool isExpired) + function _calcPartialLiquidationPayments(uint256 amount, address token, bool isExpired) internal view returns (uint256 repaidAmount, uint256 feeAmount, uint256 seizedAmount) { + address priceOracle = ICreditManagerV3(creditManager).priceOracle(); ( , uint16 feeLiquidation, @@ -1016,14 +1025,9 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT return _expirationDate != 0 && block.timestamp >= _expirationDate; // U:[FA-46] } - /// @dev Internal wrapper for `creditManager.priceOracle` call to reduce contract size - function _priceOracle() internal view returns (address) { - return ICreditManagerV3(creditManager).priceOracle(); - } - - /// @dev Internal wrapper for `priceOracle.updatePrices` call to reduce contract size - function _updatePrices(address priceOracle, PriceUpdate[] memory updates) internal { - IPriceOracleV3(priceOracle).updatePrices(updates); + /// @dev Internal wrapper for `priceFeedStore.updatePrices` call to reduce contract size + function _updatePrices(PriceUpdate[] memory updates) internal { + IPriceFeedStore(priceFeedStore).updatePrices(updates); } /// @dev Internal wrapper for `creditManager.addCollateral` call to reduce contract size diff --git a/contracts/interfaces/ICreditFacadeV3.sol b/contracts/interfaces/ICreditFacadeV3.sol index 8c4e1c83..fc3c2684 100644 --- a/contracts/interfaces/ICreditFacadeV3.sol +++ b/contracts/interfaces/ICreditFacadeV3.sol @@ -5,8 +5,8 @@ pragma solidity ^0.8.17; import {AllowanceAction} from "./ICreditConfiguratorV3.sol"; import "./ICreditFacadeV3Multicall.sol"; -import {PriceUpdate} from "./IPriceOracleV3.sol"; import {IACLTrait} from "./base/IACLTrait.sol"; +import {PriceUpdate} from "./base/IPriceFeedStore.sol"; import {IVersion} from "./base/IVersion.sol"; /// @notice Multicall element @@ -85,6 +85,8 @@ interface ICreditFacadeV3 is IVersion, IACLTrait, ICreditFacadeV3Events { function treasury() external view returns (address); + function priceFeedStore() external view returns (address); + function degenNFT() external view returns (address); function weth() external view returns (address); diff --git a/contracts/interfaces/ICreditFacadeV3Multicall.sol b/contracts/interfaces/ICreditFacadeV3Multicall.sol index 03f33915..6cc307a0 100644 --- a/contracts/interfaces/ICreditFacadeV3Multicall.sol +++ b/contracts/interfaces/ICreditFacadeV3Multicall.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import {BalanceDelta} from "../libraries/BalancesLogic.sol"; -import {PriceUpdate} from "./IPriceOracleV3.sol"; +import {PriceUpdate} from "./base/IPriceFeedStore.sol"; // ----------- // // PERMISSIONS // diff --git a/contracts/interfaces/IPriceOracleV3.sol b/contracts/interfaces/IPriceOracleV3.sol index 5b32724b..8e0b0c5d 100644 --- a/contracts/interfaces/IPriceOracleV3.sol +++ b/contracts/interfaces/IPriceOracleV3.sol @@ -18,23 +18,12 @@ struct PriceFeedParams { uint8 tokenDecimals; } -/// @notice On-demand price update params -/// @param priceFeed Price feed to update, must be in the set of updatable feeds in the price oracle -/// @param data Update data -struct PriceUpdate { - address priceFeed; - bytes data; -} - interface IPriceOracleV3Events { /// @notice Emitted when new price feed is set for token event SetPriceFeed(address indexed token, address indexed priceFeed, uint32 stalenessPeriod, bool skipCheck); /// @notice Emitted when new reserve price feed is set for token event SetReservePriceFeed(address indexed token, address indexed priceFeed, uint32 stalenessPeriod, bool skipCheck); - - /// @notice Emitted when new updatable price feed is added - event AddUpdatablePriceFeed(address indexed priceFeed); } /// @title Price oracle V3 interface @@ -67,14 +56,6 @@ interface IPriceOracleV3 is IVersion, IACLTrait, IPriceOracleV3Events { function safeConvertToUSD(uint256 amount, address token) external view returns (uint256); - // ------------- // - // PRICE UPDATES // - // ------------- // - - function getUpdatablePriceFeeds() external view returns (address[] memory); - - function updatePrices(PriceUpdate[] calldata updates) external; - // ------------- // // CONFIGURATION // // ------------- // @@ -82,6 +63,4 @@ interface IPriceOracleV3 is IVersion, IACLTrait, IPriceOracleV3Events { function setPriceFeed(address token, address priceFeed, uint32 stalenessPeriod) external; function setReservePriceFeed(address token, address priceFeed, uint32 stalenessPeriod) external; - - function addUpdatablePriceFeed(address priceFeed) external; } diff --git a/contracts/interfaces/base/IPriceFeedStore.sol b/contracts/interfaces/base/IPriceFeedStore.sol new file mode 100644 index 00000000..df0dc13e --- /dev/null +++ b/contracts/interfaces/base/IPriceFeedStore.sol @@ -0,0 +1,16 @@ +// 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"; + +struct PriceUpdate { + address priceFeed; + bytes data; +} + +interface IPriceFeedStore { + function getStalenessPeriod(address priceFeed) external view returns (uint32); + function updatePrices(PriceUpdate[] calldata updates) external; +} diff --git a/contracts/libraries/Constants.sol b/contracts/libraries/Constants.sol index 96e02199..d20cf41e 100644 --- a/contracts/libraries/Constants.sol +++ b/contracts/libraries/Constants.sol @@ -6,6 +6,7 @@ pragma solidity ^0.8.17; bytes32 constant AP_GEAR_TOKEN = "GEAR_TOKEN"; bytes32 constant AP_INSTANCE_MANAGER_PROXY = "INSTANCE_MANAGER_PROXY"; bytes32 constant AP_CROSS_CHAIN_GOVERNANCE_PROXY = "CROSS_CHAIN_GOVERNANCE_PROXY"; +bytes32 constant AP_PRICE_FEED_STORE = "PRICE_FEED_STORE"; uint256 constant NO_VERSION_CONTROL = 0; uint256 constant WAD = 1e18; diff --git a/contracts/test/helpers/IntegrationTestHelper.sol b/contracts/test/helpers/IntegrationTestHelper.sol index 3ad610e5..50ee72c6 100644 --- a/contracts/test/helpers/IntegrationTestHelper.sol +++ b/contracts/test/helpers/IntegrationTestHelper.sol @@ -9,6 +9,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {DefaultAccountFactoryV3} from "../../core/DefaultAccountFactoryV3.sol"; import {IACL} from "../../interfaces//base/IACL.sol"; import {IContractsRegister} from "../../interfaces/base/IContractsRegister.sol"; +import {IPriceFeedStore} from "../../interfaces/base/IPriceFeedStore.sol"; import {IWETH} from "../../interfaces/external/IWETH.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -65,6 +66,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { // CORE IACL acl; IContractsRegister cr; + IPriceFeedStore priceFeedStore; DefaultAccountFactoryV3 accountFactory; IPriceOracleV3 priceOracle; ILossPolicy lossPolicy; @@ -239,6 +241,7 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { accountFactory = gp.accountFactory(); botList = gp.botList(); cr = gp.contractsRegister(); + priceFeedStore = gp.priceFeedStore(); gearStaking = gp.gearStaking(); vm.warp(gearStaking.firstEpochTimestamp()); @@ -434,7 +437,8 @@ contract IntegrationTestHelper is TestHelper, BalanceHelper, ConfigManager { degenNFT: (whitelisted) ? address(degenNFT) : address(0), expirable: (anyExpirable) ? cmParams.expirable : expirable }); - CreditManagerFactory cmf = new CreditManagerFactory(address(pool), managerParams, facadeParams); + CreditManagerFactory cmf = + new CreditManagerFactory(address(acl), address(pool), managerParams, facadeParams); creditManager = cmf.creditManager(); creditFacade = cmf.creditFacade(); diff --git a/contracts/test/integration/credit/CreditConfigurator.int.t.sol b/contracts/test/integration/credit/CreditConfigurator.int.t.sol index 22c1b91a..d82fedf8 100644 --- a/contracts/test/integration/credit/CreditConfigurator.int.t.sol +++ b/contracts/test/integration/credit/CreditConfigurator.int.t.sol @@ -801,7 +801,13 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf if (expirable) { CreditFacadeV3 initialCf = new CreditFacadeV3( - address(creditManager), address(lossPolicy), address(botList), address(0), address(0), true + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + true ); vm.prank(CONFIGURATOR); @@ -817,7 +823,13 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf creditConfigurator.setMaxDebtPerBlockMultiplier(DEFAULT_LIMIT_PER_BLOCK_MULTIPLIER + 1); CreditFacadeV3 cf = new CreditFacadeV3( - address(creditManager), address(lossPolicy), address(botList), address(0), address(0), expirable + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + expirable ); uint8 maxDebtPerBlockMultiplier = creditFacade.maxDebtPerBlockMultiplier(); @@ -859,7 +871,7 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf vm.startPrank(CONFIGURATOR); CreditFacadeV3 cf = new CreditFacadeV3( - address(creditManager), address(lossPolicy), address(botList), address(0), address(0), false + 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(); @@ -898,7 +910,13 @@ contract CreditConfiguratorIntegrationTest is IntegrationTestHelper, ICreditConf vm.stopPrank(); CreditFacadeV3 cf = new CreditFacadeV3( - address(creditManager), address(lossPolicy), address(botList), address(0), address(0), false + address(acl), + address(creditManager), + address(lossPolicy), + address(botList), + address(0), + address(0), + false ); vm.prank(CONFIGURATOR); diff --git a/contracts/test/integration/credit/LiquidateCreditAccount.int.t.sol b/contracts/test/integration/credit/LiquidateCreditAccount.int.t.sol index 92278f9b..b20beb9b 100644 --- a/contracts/test/integration/credit/LiquidateCreditAccount.int.t.sol +++ b/contracts/test/integration/credit/LiquidateCreditAccount.int.t.sol @@ -10,7 +10,7 @@ import { ICreditManagerV3Events, ManageDebtAction } from "../../../interfaces/ICreditManagerV3.sol"; -import {IPriceOracleV3, PriceUpdate} from "../../../interfaces/IPriceOracleV3.sol"; +import {IPriceFeedStore, PriceUpdate} from "../../../interfaces/base/IPriceFeedStore.sol"; import "../../../interfaces/ICreditFacadeV3.sol"; import {MultiCallBuilder} from "../../lib/MultiCallBuilder.sol"; @@ -111,7 +111,7 @@ contract LiquidateCreditAccountIntegrationTest is IntegrationTestHelper, ICredit }) ); - vm.expectCall(address(priceOracle), abi.encodeCall(IPriceOracleV3.updatePrices, (priceUpdates))); + vm.expectCall(address(priceFeedStore), abi.encodeCall(IPriceFeedStore.updatePrices, (priceUpdates))); vm.expectCall( address(creditManager), diff --git a/contracts/test/interfaces/IAddressProviderV3.sol b/contracts/test/interfaces/IAddressProviderV3.sol index 381a2c09..cc4555e7 100644 --- a/contracts/test/interfaces/IAddressProviderV3.sol +++ b/contracts/test/interfaces/IAddressProviderV3.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.17; import { AP_GEAR_TOKEN, + AP_PRICE_FEED_STORE, AP_INSTANCE_MANAGER_PROXY, AP_CROSS_CHAIN_GOVERNANCE_PROXY, NO_VERSION_CONTROL diff --git a/contracts/test/mocks/core/AddressProviderV3ACLMock.sol b/contracts/test/mocks/core/AddressProviderV3ACLMock.sol index e42b6d53..89abcf95 100644 --- a/contracts/test/mocks/core/AddressProviderV3ACLMock.sol +++ b/contracts/test/mocks/core/AddressProviderV3ACLMock.sol @@ -14,6 +14,7 @@ import "../../interfaces/IAddressProviderV3.sol"; import {AccountFactoryMock} from "../core/AccountFactoryMock.sol"; import {PriceOracleMock} from "../oracles/PriceOracleMock.sol"; +import {PriceFeedStoreMock} from "../oracles/PriceFeedStoreMock.sol"; import {BotListMock} from "../core/BotListMock.sol"; import {WETHMock} from "../token/WETHMock.sol"; @@ -32,6 +33,9 @@ contract AddressProviderV3ACLMock is Test, IAddressProviderV3, Ownable { PriceOracleMock priceOracleMock = new PriceOracleMock(); _setAddress(AP_PRICE_ORACLE, address(priceOracleMock), priceOracleMock.version()); + PriceFeedStoreMock priceFeedStoreMock = new PriceFeedStoreMock(); + _setAddress(AP_PRICE_FEED_STORE, address(priceFeedStoreMock), 0); + AccountFactoryMock accountFactoryMock = new AccountFactoryMock(3_10); _setAddress(AP_ACCOUNT_FACTORY, address(accountFactoryMock), 3_10); diff --git a/contracts/test/mocks/oracles/PriceFeedStoreMock.sol b/contracts/test/mocks/oracles/PriceFeedStoreMock.sol new file mode 100644 index 00000000..a5758675 --- /dev/null +++ b/contracts/test/mocks/oracles/PriceFeedStoreMock.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IUpdatablePriceFeed} from "../../../interfaces/base/IPriceFeed.sol"; +import {IPriceFeedStore, PriceUpdate} from "../../../interfaces/base/IPriceFeedStore.sol"; + +contract PriceFeedStoreMock is IPriceFeedStore { + uint256 public constant version = 3_10; + bytes32 public constant contractType = "PRICE_FEED_STORE::MOCK"; + + mapping(address => uint32) internal _stalenessPeriods; + + function setStalenessPeriod(address priceFeed, uint32 stalenessPeriod) external { + _stalenessPeriods[priceFeed] = stalenessPeriod; + } + + function getStalenessPeriod(address priceFeed) external view returns (uint32) { + return _stalenessPeriods[priceFeed]; + } + + function updatePrices(PriceUpdate[] calldata updates) external { + for (uint256 i; i < updates.length; i++) { + IUpdatablePriceFeed(updates[i].priceFeed).updatePrice(updates[i].data); + } + } +} diff --git a/contracts/test/mocks/oracles/PriceOracleMock.sol b/contracts/test/mocks/oracles/PriceOracleMock.sol index 6618a68e..00bcc394 100644 --- a/contracts/test/mocks/oracles/PriceOracleMock.sol +++ b/contracts/test/mocks/oracles/PriceOracleMock.sol @@ -4,8 +4,6 @@ pragma solidity ^0.8.17; //pragma abicoder v1; -import {PriceUpdate} from "../../../interfaces/IPriceOracleV3.sol"; - // EXCEPTIONS import {Test} from "forge-std/Test.sol"; @@ -22,8 +20,6 @@ contract PriceOracleMock is Test { constructor() {} - function updatePrices(PriceUpdate[] calldata) external pure {} - function priceFeeds(address token) public view returns (address) { return priceFeedsInt[token][false]; } diff --git a/contracts/test/suites/CreditManagerFactory.sol b/contracts/test/suites/CreditManagerFactory.sol index 153ebdd4..0b76bb0e 100644 --- a/contracts/test/suites/CreditManagerFactory.sol +++ b/contracts/test/suites/CreditManagerFactory.sol @@ -35,7 +35,7 @@ contract CreditManagerFactory { bool expirable; } - constructor(address pool, ManagerParams memory cmParams, FacadeParams memory cfParams) { + constructor(address addressProvider, address pool, ManagerParams memory cmParams, FacadeParams memory cfParams) { creditManager = new CreditManagerV3( pool, cmParams.accountFactory, @@ -50,6 +50,7 @@ contract CreditManagerFactory { ); creditFacade = new CreditFacadeV3( + addressProvider, address(creditManager), cfParams.lossPolicy, cfParams.botList, diff --git a/contracts/test/suites/GenesisFactory.sol b/contracts/test/suites/GenesisFactory.sol index ea0c3f5d..828e32d1 100644 --- a/contracts/test/suites/GenesisFactory.sol +++ b/contracts/test/suites/GenesisFactory.sol @@ -12,6 +12,7 @@ import {DefaultAccountFactoryV3} from "../../core/DefaultAccountFactoryV3.sol"; import {GearStakingV3} from "../../core/GearStakingV3.sol"; import {BotListV3} from "../../core/BotListV3.sol"; import {PriceFeedConfig} from "../interfaces/ICreditConfig.sol"; +import {IPriceFeedStore} from "../../interfaces/base/IPriceFeedStore.sol"; import {IContractsRegister} from "../../interfaces/base/IContractsRegister.sol"; import {GearStakingV3} from "../../core/GearStakingV3.sol"; @@ -26,6 +27,7 @@ contract GenesisFactory is Ownable { BotListV3 public botList; DefaultAccountFactoryV3 public accountFactory; IContractsRegister public contractsRegister; + IPriceFeedStore public priceFeedStore; GearStakingV3 public gearStaking; constructor() { @@ -36,6 +38,7 @@ contract GenesisFactory is Ownable { lossPolicy = new LossPolicyMock(); accountFactory = new DefaultAccountFactoryV3(address(acl)); botList = new BotListV3(address(acl)); + priceFeedStore = IPriceFeedStore(acl.getAddressOrRevert(AP_PRICE_FEED_STORE, 0)); ERC20 gearToken = new ERC20("Gearbox", "GEAR"); acl.setAddress(AP_GEAR_TOKEN, address(gearToken), false); diff --git a/contracts/test/unit/core/PriceOracleV3.unit.t.sol b/contracts/test/unit/core/PriceOracleV3.unit.t.sol index 2fd427dd..db692b1d 100644 --- a/contracts/test/unit/core/PriceOracleV3.unit.t.sol +++ b/contracts/test/unit/core/PriceOracleV3.unit.t.sol @@ -7,12 +7,11 @@ import {Test} from "forge-std/Test.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "../../../interfaces/IExceptions.sol"; -import {IPriceOracleV3Events, PriceFeedParams, PriceUpdate} from "../../../interfaces/IPriceOracleV3.sol"; -import {IPriceFeed, IUpdatablePriceFeed} from "../../../interfaces/base/IPriceFeed.sol"; +import {IPriceOracleV3Events, PriceFeedParams} from "../../../interfaces/IPriceOracleV3.sol"; +import {IPriceFeed} from "../../../interfaces/base/IPriceFeed.sol"; import {ERC20Mock} from "../../mocks/token/ERC20Mock.sol"; import {PriceFeedMock} from "../../mocks/oracles/PriceFeedMock.sol"; -import {UpdatablePriceFeedMock} from "../../mocks/oracles/UpdatablePriceFeedMock.sol"; import {AddressProviderV3ACLMock} from "../../mocks/core/AddressProviderV3ACLMock.sol"; import {PriceFeedFallbackMock} from "../../mocks/oracles/PriceFeedFallbackMock.sol"; @@ -213,45 +212,6 @@ contract PriceOracleV3UnitTest is Test, IPriceOracleV3Events { assertEq(params.tokenDecimals, 18, "Incorrect decimals"); } - /// @notice U:[PO-5]: `addUpdatablePriceFeed` works as expected - function test_U_PO_05_addUpdatablePriceFeed_works_as_expected() public { - UpdatablePriceFeedMock priceFeed = new UpdatablePriceFeedMock(); - priceFeed.setUpdatable(UpdatablePriceFeedMock.FlagState.REVERT); - PriceUpdate[] memory updates = new PriceUpdate[](1); - updates[0] = PriceUpdate(address(priceFeed), "DUMMY DATA"); - - // revert cases - vm.expectRevert(ZeroAddressException.selector); - priceOracle.addUpdatablePriceFeed(address(0)); - - vm.expectRevert(CallerNotConfiguratorException.selector); - priceOracle.addUpdatablePriceFeed(address(priceFeed)); - - vm.expectRevert(PriceFeedIsNotUpdatableException.selector); - vm.prank(configurator); - priceOracle.addUpdatablePriceFeed(address(priceFeed)); - - vm.expectRevert(PriceFeedIsNotUpdatableException.selector); - priceOracle.updatePrices(updates); - - // adding price feed - priceFeed.setUpdatable(UpdatablePriceFeedMock.FlagState.TRUE); - - vm.expectEmit(true, true, true, true); - emit AddUpdatablePriceFeed(address(priceFeed)); - - vm.prank(configurator); - priceOracle.addUpdatablePriceFeed(address(priceFeed)); - - address[] memory feeds = priceOracle.getUpdatablePriceFeeds(); - assertEq(feeds.length, 1, "Price feed not added to the set"); - assertEq(feeds[0], address(priceFeed), "Wrong price feed added to the set"); - - // updating price feed - vm.expectCall(address(priceFeed), abi.encodeCall(priceFeed.updatePrice, ("DUMMY DATA"))); - priceOracle.updatePrices(updates); - } - // ------------------ // // INTERNAL FUNCTIONS // // ------------------ // @@ -397,14 +357,12 @@ contract PriceOracleV3UnitTest is Test, IPriceOracleV3Events { assertEq(priceOracle.exposed_getValidatedPrice(priceFeed, 20, false), 123, "Incorrect price"); } - /// @notice U:[PO-10]: `_validatePriceFeed` and `_isUpdatable` work correctly for price feeds with fallback - function test_U_PO_10_validatePriceFeed_and_isUpdatable_work_correctly_for_price_feeds_with_fallback() public { + /// @notice U:[PO-10]: `_validatePriceFeed` works correctly for price feeds with fallback + function test_U_PO_10_validatePriceFeed_works_correctly_for_price_feeds_with_fallback() public { address priceFeed = address(new PriceFeedFallbackMock(1e8, 8, false)); assertFalse(priceOracle.exposed_validatePriceFeed(priceFeed, 1000)); - assertFalse(priceOracle.exposed_isUpdatable(priceFeed)); priceFeed = address(new PriceFeedFallbackMock(1e8, 8, true)); assertFalse(priceOracle.exposed_validatePriceFeed(priceFeed, 1000)); - assertFalse(priceOracle.exposed_isUpdatable(priceFeed)); } } diff --git a/contracts/test/unit/core/PriceOracleV3Harness.sol b/contracts/test/unit/core/PriceOracleV3Harness.sol index f58edee2..bffe00a0 100644 --- a/contracts/test/unit/core/PriceOracleV3Harness.sol +++ b/contracts/test/unit/core/PriceOracleV3Harness.sol @@ -39,8 +39,4 @@ contract PriceOracleV3Harness is PriceOracleV3 { { return _getValidatedPrice(priceFeed, stalenessPeriod, skipCheck); } - - function exposed_isUpdatable(address priceFeed) external view returns (bool) { - return _isUpdatable(priceFeed); - } } diff --git a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol index bd01fdc5..e873a4dc 100644 --- a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol @@ -26,6 +26,7 @@ import {PriceOracleMock} from "../../mocks/oracles/PriceOracleMock.sol"; import {UpdatablePriceFeedMock} from "../../mocks/oracles/UpdatablePriceFeedMock.sol"; import {AdapterCallMock} from "../../mocks/core/AdapterCallMock.sol"; import {PoolMock} from "../../mocks/pool/PoolMock.sol"; +import {PriceFeedStoreMock} from "../../mocks/oracles/PriceFeedStoreMock.sol"; import {ENTERED} from "../../../traits/ReentrancyGuardTrait.sol"; @@ -38,7 +39,8 @@ import { } from "../../../interfaces/ICreditManagerV3.sol"; import {AllowanceAction} from "../../../interfaces/ICreditConfiguratorV3.sol"; import {IBotListV3} from "../../../interfaces/IBotListV3.sol"; -import {IPriceOracleV3, PriceUpdate} from "../../../interfaces/IPriceOracleV3.sol"; +import {IPriceFeedStore, PriceUpdate} from "../../../interfaces/base/IPriceFeedStore.sol"; +import {IUpdatablePriceFeed} from "../../../interfaces/base/IPriceFeed.sol"; import {BitMask} from "../../../libraries/BitMask.sol"; import {BalanceWithMask} from "../../../libraries/BalancesLogic.sol"; @@ -46,6 +48,7 @@ import {MultiCallBuilder} from "../../lib/MultiCallBuilder.sol"; // CONSTANTS import { + AP_PRICE_FEED_STORE, BOT_PERMISSIONS_SET_FLAG, DEFAULT_LIMIT_PER_BLOCK_MULTIPLIER, PERCENTAGE_FACTOR, @@ -78,6 +81,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve PriceOracleMock priceOracleMock; LossPolicyMock lossPolicyMock; BotListMock botListMock; + PriceFeedStoreMock priceFeedStoreMock; DegenNFTMock degenNFTMock; address treasury; @@ -136,6 +140,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve addressProvider.setAddress(AP_WETH_TOKEN, tokenTestSuite.addressOf(TOKEN_WETH), false); botListMock = BotListMock(addressProvider.getAddressOrRevert(AP_BOT_LIST, 3_10)); + priceFeedStoreMock = PriceFeedStoreMock(addressProvider.getAddressOrRevert(AP_PRICE_FEED_STORE, 0)); priceOracleMock = PriceOracleMock(addressProvider.getAddressOrRevert(AP_PRICE_ORACLE, 3_10)); @@ -172,6 +177,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve function _deploy() internal { creditFacade = new CreditFacadeV3Harness( + address(addressProvider), address(creditManagerMock), address(lossPolicyMock), address(botListMock), @@ -186,6 +192,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve /// @dev U:[FA-1]: constructor sets correct values function test_U_FA_01_constructor_sets_correct_values() public allDegenNftCases allExpirableCases { assertEq(creditFacade.creditManager(), address(creditManagerMock), "Incorrect creditManager"); + assertEq(creditFacade.priceFeedStore(), address(priceFeedStoreMock), "Incorrect priceFeedStore"); assertEq(creditFacade.underlying(), tokenTestSuite.addressOf(TOKEN_DAI), "Incorrect underlying"); assertEq(creditFacade.treasury(), treasury, "Incorrect treasury"); @@ -198,11 +205,18 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve vm.expectRevert(ZeroAddressException.selector); new CreditFacadeV3Harness( - address(creditManagerMock), address(0), address(botListMock), address(0), address(degenNFTMock), expirable + 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), @@ -684,6 +698,8 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve /// @dev U:[FA-15]: `_calcPartialLiquidationPayments` works as expected function test_U_FA_15_calcPartialLiquidationPayments_works_as_expected() public notExpirableCase { + creditManagerMock.setPriceOracle(address(priceOracleMock)); + address dai = tokenTestSuite.addressOf(TOKEN_DAI); address link = tokenTestSuite.addressOf(TOKEN_LINK); priceOracleMock.setPrice(dai, 1e8); @@ -701,12 +717,8 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve assertEq(expiredLiquidationDiscount, 98_00, "[setup]: Incorrect expired liquidation discount"); assertEq(expiredLiquidationFee, 1_00, "[setup]: Incorrect expired liquidation fee"); - (uint256 repaidAmount, uint256 feeAmount, uint256 seizedAmount) = creditFacade.calcPartialLiquidationPayments({ - amount: 1000e18, - token: link, - priceOracle: address(priceOracleMock), - isExpired: false - }); + (uint256 repaidAmount, uint256 feeAmount, uint256 seizedAmount) = + creditFacade.calcPartialLiquidationPayments({amount: 1000e18, token: link, isExpired: false}); assertEq(repaidAmount, 985e18, "Incorrect repaidAmount (non-expired case)"); assertEq(feeAmount, 15e18, "Incorrect feeAmount (non-expired case)"); @@ -716,12 +728,8 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve "Incorrect seizedAmount (non-expired case)" ); - (repaidAmount, feeAmount, seizedAmount) = creditFacade.calcPartialLiquidationPayments({ - amount: 1000e18, - token: link, - priceOracle: address(priceOracleMock), - isExpired: true - }); + (repaidAmount, feeAmount, seizedAmount) = + creditFacade.calcPartialLiquidationPayments({amount: 1000e18, token: link, isExpired: true}); assertEq(repaidAmount, 990e18, "Incorrect repaidAmount (expired case)"); assertEq(feeAmount, 10e18, "Incorrect feeAmount (expired case)"); @@ -1363,11 +1371,16 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve function test_U_FA_25_multicall_onDemandPriceUpdates_works_properly() public notExpirableCase { creditManagerMock.setPriceOracle(address(priceOracleMock)); + address token0 = makeAddr("token0"); + address token1 = makeAddr("token1"); PriceUpdate[] memory updates = new PriceUpdate[](2); - updates[0] = PriceUpdate(makeAddr("token0"), "data0"); - updates[1] = PriceUpdate(makeAddr("token1"), "data1"); + updates[0] = PriceUpdate(token0, "data0"); + updates[1] = PriceUpdate(token1, "data1"); + + vm.mockCall(token0, abi.encodeCall(IUpdatablePriceFeed.updatePrice, ("data0")), abi.encode()); + vm.mockCall(token1, abi.encodeCall(IUpdatablePriceFeed.updatePrice, ("data1")), abi.encode()); - vm.expectCall(address(priceOracleMock), abi.encodeCall(IPriceOracleV3.updatePrices, (updates))); + vm.expectCall(address(priceFeedStoreMock), abi.encodeCall(IPriceFeedStore.updatePrices, (updates))); creditFacade.multicallInt({ creditAccount: DUMB_ADDRESS, calls: MultiCallBuilder.build( diff --git a/contracts/test/unit/credit/CreditFacadeV3Harness.sol b/contracts/test/unit/credit/CreditFacadeV3Harness.sol index 7d8a14d8..ff6efa7f 100644 --- a/contracts/test/unit/credit/CreditFacadeV3Harness.sol +++ b/contracts/test/unit/credit/CreditFacadeV3Harness.sol @@ -10,13 +10,14 @@ import {BalanceWithMask} from "../../../libraries/BalancesLogic.sol"; contract CreditFacadeV3Harness is CreditFacadeV3 { constructor( + address _addressProvider, address _creditManager, address _lossPolicy, address _botList, address _weth, address _degenNFT, bool _expirable - ) CreditFacadeV3(_creditManager, _lossPolicy, _botList, _weth, _degenNFT, _expirable) {} + ) CreditFacadeV3(_addressProvider, _creditManager, _lossPolicy, _botList, _weth, _degenNFT, _expirable) {} function setReentrancy(uint8 _status) external { _reentrancyStatus = _status; @@ -40,12 +41,12 @@ contract CreditFacadeV3Harness is CreditFacadeV3 { return _revertIfNotLiquidatable(creditAccount); } - function calcPartialLiquidationPayments(uint256 amount, address token, address priceOracle, bool isExpired) + function calcPartialLiquidationPayments(uint256 amount, address token, bool isExpired) external view returns (uint256, uint256, uint256) { - return _calcPartialLiquidationPayments(amount, token, priceOracle, isExpired); + return _calcPartialLiquidationPayments(amount, token, isExpired); } function setLastBlockBorrowed(uint64 _lastBlockBorrowed) external { diff --git a/contracts/traits/PriceFeedValidationTrait.sol b/contracts/traits/PriceFeedValidationTrait.sol index 5c73b7dd..7613aa6e 100644 --- a/contracts/traits/PriceFeedValidationTrait.sol +++ b/contracts/traits/PriceFeedValidationTrait.sol @@ -74,20 +74,4 @@ abstract contract PriceFeedValidationTrait { (, answer,, updatedAt,) = IPriceFeed(priceFeed).latestRoundData(); if (!skipCheck) _checkAnswer(answer, updatedAt, stalenessPeriod); } - - /// @dev Checks whether price feed is updatable - /// @custom:tests U:[PO-10] - function _isUpdatable(address priceFeed) internal view returns (bool updatable) { - // NOTE: Some external price feeds without `updatable` may have a fallback function that changes state, - // which can cause a `THROW` that burns all gas, or does not change state and instead returns empty data. - // To handle these cases, we use a special call construction with a strict gas limit. - (bool success, bytes memory returnData) = OptionalCall.staticCallOptionalSafe({ - target: priceFeed, - data: abi.encodeWithSelector(IUpdatablePriceFeed.updatable.selector), - gasAllowance: 10_000 - }); - if (success) { - updatable = abi.decode(returnData, (bool)); - } - } } From 50cb27ad4e77f42bc1bfebdf2515fe2a7d7e3854 Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Tue, 18 Feb 2025 21:54:17 +0200 Subject: [PATCH 2/3] feat: add `AliasedLossPolicyV3` --- contracts/core/AliasedLossPolicyV3.sol | 305 ++++++++++ contracts/credit/CreditFacadeV3.sol | 7 +- contracts/interfaces/IAliasedLossPolicyV3.sol | 33 ++ contracts/interfaces/base/ILossPolicy.sol | 47 +- contracts/test/mocks/core/LossPolicyMock.sol | 25 +- .../test/mocks/oracles/PriceOracleMock.sol | 2 + .../unit/core/AliasedLossPolicyV3.unit.t.sol | 550 ++++++++++++++++++ .../unit/core/AliasedLossPolicyV3Harness.sol | 61 ++ .../unit/credit/CreditFacadeV3.unit.t.sol | 14 +- 9 files changed, 1021 insertions(+), 23 deletions(-) create mode 100644 contracts/core/AliasedLossPolicyV3.sol create mode 100644 contracts/interfaces/IAliasedLossPolicyV3.sol create mode 100644 contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol create mode 100644 contracts/test/unit/core/AliasedLossPolicyV3Harness.sol diff --git a/contracts/core/AliasedLossPolicyV3.sol b/contracts/core/AliasedLossPolicyV3.sol new file mode 100644 index 00000000..12fc5bd4 --- /dev/null +++ b/contracts/core/AliasedLossPolicyV3.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IAliasedLossPolicyV3} from "../interfaces/IAliasedLossPolicyV3.sol"; +import {ICreditAccountV3} from "../interfaces/ICreditAccountV3.sol"; +import {ICreditManagerV3} from "../interfaces/ICreditManagerV3.sol"; +import {IPoolQuotaKeeperV3} from "../interfaces/IPoolQuotaKeeperV3.sol"; +import {IPoolV3} from "../interfaces/IPoolV3.sol"; +import {IPriceOracleV3, PriceFeedParams} from "../interfaces/IPriceOracleV3.sol"; +import {IAddressProvider} from "../interfaces/base/IAddressProvider.sol"; +import {IPriceFeedStore, PriceUpdate} from "../interfaces/base/IPriceFeedStore.sol"; + +import {BitMask} from "../libraries/BitMask.sol"; +import { + AP_PRICE_FEED_STORE, + NO_VERSION_CONTROL, + PERCENTAGE_FACTOR, + RAY, + UNDERLYING_TOKEN_MASK +} from "../libraries/Constants.sol"; +import {MarketHelper} from "../libraries/MarketHelper.sol"; + +import {ACLTrait} from "../traits/ACLTrait.sol"; +import {PriceFeedValidationTrait} from "../traits/PriceFeedValidationTrait.sol"; + +import {TokenIsNotQuotedException} from "../interfaces/IExceptions.sol"; + +/// @title Aliased loss policy V3 +/// @notice Loss policy that allows to double-check the decision on whether to liquidate a credit account with bad debt +/// using TWV recomputed with alias price feeds. This can be useful in scenarios where token's market price +/// drops for a short period of time while its fundamental value remains the same. +/// It also allows to restrict such liquidations to only be performed by accounts with `LOSS_LIQUIDATOR` role +/// which can then return premium to recover part of the losses. +contract AliasedLossPolicyV3 is ACLTrait, PriceFeedValidationTrait, IAliasedLossPolicyV3 { + using BitMask for uint256; + using EnumerableSet for EnumerableSet.AddressSet; + using MarketHelper for IPoolV3; + + /// @dev Internal struct that contains shared info needed for collateral calculation + struct SharedInfo { + address creditManager; + address priceOracle; + address quotaKeeper; + uint256 underlyingPriceRAY; + } + + /// @dev Internal struct that contains token info needed for collateral calculation + struct TokenInfo { + address token; + uint16 lt; + uint256 balance; + uint256 quotaUSD; + PriceFeedParams aliasParams; + } + + /// @notice Contract version + uint256 public constant override version = 3_10; + + /// @notice Contract type + bytes32 public constant override contractType = "LOSS_POLICY::ALIASED"; + + /// @notice Pool for which the loss policy is applied + address public immutable override pool; + + /// @notice Pool's underlying token + address public immutable override underlying; + + /// @notice Price feed store + address public immutable override priceFeedStore; + + /// @notice Access mode for loss liquidations + AccessMode public override accessMode = AccessMode.Permissionless; + + /// @notice Whether policy checks are enabled + bool public override checksEnabled = true; + + /// @dev Set of tokens that have alias price feeds + EnumerableSet.AddressSet internal _tokensWithAliasSet; + + /// @dev Mapping from token to its alias price feed parameters + mapping(address => PriceFeedParams) internal _aliasPriceFeedParams; + + /// @notice Constructor + /// @param pool_ Pool address + /// @param addressProvider_ Address provider contract address + /// @custom:tests U:[ALP-1] + constructor(address pool_, address addressProvider_) ACLTrait(IPoolV3(pool_).getACL()) { + pool = pool_; + underlying = IPoolV3(pool_).asset(); + priceFeedStore = IAddressProvider(addressProvider_).getAddressOrRevert(AP_PRICE_FEED_STORE, NO_VERSION_CONTROL); + } + + // ------- // + // GETTERS // + // ------- // + + /// @notice Serializes the loss policy state + /// @custom:tests U:[ALP-1], U:[ALP-2], U:[ALP-3] + function serialize() external view override returns (bytes memory) { + address[] memory tokens = _tokensWithAliasSet.values(); + uint256 numTokens = tokens.length; + PriceFeedParams[] memory priceFeedParams = new PriceFeedParams[](numTokens); + for (uint256 i; i < numTokens; ++i) { + priceFeedParams[i] = _aliasPriceFeedParams[tokens[i]]; + } + return abi.encode(accessMode, checksEnabled, tokens, priceFeedParams); + } + + /// @notice Returns whether `creditAccount` can be liquidated with loss by `caller` + /// @custom:tests U:[ALP-4], U:[ALP-5] + function isLiquidatable(address creditAccount, address caller, Params calldata params) + external + override + returns (bool) + { + AccessMode accessMode_ = accessMode; + if (accessMode_ == AccessMode.Forbidden) return false; + if (accessMode_ == AccessMode.Permissioned && !_hasRole("LOSS_LIQUIDATOR", caller)) return false; + if (!checksEnabled) return true; + + _updatePrices(params.extraData); + + return _adjustForAliases(creditAccount, params.twvUSD) < params.totalDebtUSD; + } + + /// @notice Returns the list of tokens that have alias price feeds + function getTokensWithAlias() external view override returns (address[] memory) { + return _tokensWithAliasSet.values(); + } + + /// @notice Returns `token`'s alias price feed parameters + function getAliasPriceFeedParams(address token) external view override returns (PriceFeedParams memory) { + return _aliasPriceFeedParams[token]; + } + + /// @notice Returns the list of alias price feeds that need to return a valid price to liquidate `creditAccount` + /// @custom:tests U:[ALP-6] + function getRequiredAliasPriceFeeds(address creditAccount) + external + view + override + returns (address[] memory priceFeeds) + { + address creditManager = ICreditAccountV3(creditAccount).creditManager(); + uint256 remainingTokensMask = + ICreditManagerV3(creditManager).enabledTokensMaskOf(creditAccount).disable(UNDERLYING_TOKEN_MASK); + priceFeeds = new address[](remainingTokensMask.calcEnabledTokens()); + uint256 numAliases; + while (remainingTokensMask != 0) { + uint256 tokenMask = remainingTokensMask.lsbMask(); + remainingTokensMask ^= tokenMask; + + address token = ICreditManagerV3(creditManager).getTokenByMask(tokenMask); + address aliasPriceFeed = _aliasPriceFeedParams[token].priceFeed; + if (aliasPriceFeed != address(0)) priceFeeds[numAliases++] = aliasPriceFeed; + } + assembly { + mstore(priceFeeds, numAliases) + } + } + + // ------------- // + // CONFIGURATION // + // ------------- // + + /// @notice Sets access mode for liquidations + /// @dev Reverts if caller is not configurator + /// @custom:tests U:[ALP-2] + function setAccessMode(AccessMode mode) external override configuratorOnly { + if (accessMode == mode) return; + accessMode = mode; + emit SetAccessMode(mode); + } + + /// @notice Enables or disables policy checks + /// @dev Reverts if caller is not configurator + /// @custom:tests U:[ALP-2] + function setChecksEnabled(bool enabled) external override configuratorOnly { + if (checksEnabled == enabled) return; + checksEnabled = enabled; + emit SetChecksEnabled(enabled); + } + + /// @notice Sets `token`'s alias price feed to `priceFeed`, unsets it if `priceFeed` is zero + /// @dev Reverts if caller is not configurator + /// @dev Reverts if `token` is not quoted (including underlying) + /// @dev Reverts if `priceFeed` is not known to the price feed store + /// @custom:tests U:[ALP-3] + function setAliasPriceFeed(address token, address priceFeed) external override configuratorOnly { + if (_aliasPriceFeedParams[token].priceFeed == priceFeed) return; + + if (!IPoolQuotaKeeperV3(IPoolV3(pool).poolQuotaKeeper()).isQuotedToken(token)) { + revert TokenIsNotQuotedException(); + } + + if (priceFeed == address(0)) { + if (_tokensWithAliasSet.remove(token)) { + delete _aliasPriceFeedParams[token]; + emit UnsetAliasPriceFeed(token); + } + return; + } + + uint32 stalenessPeriod = IPriceFeedStore(priceFeedStore).getStalenessPeriod(priceFeed); + bool skipCheck = _validatePriceFeed(priceFeed, stalenessPeriod); + _aliasPriceFeedParams[token] = PriceFeedParams({ + priceFeed: priceFeed, + stalenessPeriod: stalenessPeriod, + skipCheck: skipCheck, + tokenDecimals: ERC20(token).decimals() + }); + + _tokensWithAliasSet.add(token); + emit SetAliasPriceFeed(token, priceFeed, stalenessPeriod, skipCheck); + } + + // --------- // + // INTERNALS // + // --------- // + + /// @dev If provided non-empty `data`, updates prices in the price feed store + /// @custom:tests U:[ALP-5] + function _updatePrices(bytes calldata data) internal { + if (data.length == 0) return; + PriceUpdate[] memory priceUpdates = abi.decode(data, (PriceUpdate[])); + IPriceFeedStore(priceFeedStore).updatePrices(priceUpdates); + } + + /// @dev Adjusts credit account's TWV for difference between normal and alias price feeds + /// @custom:tests U:[ALP-7] + function _adjustForAliases(address creditAccount, uint256 twvUSD) internal view returns (uint256 twvUSDAliased) { + SharedInfo memory sharedInfo = _getSharedInfo(creditAccount); + twvUSDAliased = twvUSD; + + uint256 remainingTokensMask = + ICreditManagerV3(sharedInfo.creditManager).enabledTokensMaskOf(creditAccount).disable(UNDERLYING_TOKEN_MASK); + while (remainingTokensMask != 0) { + uint256 tokenMask = remainingTokensMask.lsbMask(); + remainingTokensMask ^= tokenMask; + + TokenInfo memory tokenInfo = _getTokenInfo(creditAccount, tokenMask, sharedInfo); + // no need to check other fields since `quotaUSD` is initialized only if all of them are non-zero + if (tokenInfo.quotaUSD == 0) continue; + + twvUSDAliased += _getWeightedValueUSD(tokenInfo, sharedInfo, true); + twvUSDAliased -= _getWeightedValueUSD(tokenInfo, sharedInfo, false); + } + } + + /// @dev Returns the shared info needed for `creditAccount` collateral value calculation + /// @custom:tests U:[ALP-8] + function _getSharedInfo(address creditAccount) internal view returns (SharedInfo memory sharedInfo) { + sharedInfo.creditManager = ICreditAccountV3(creditAccount).creditManager(); + sharedInfo.priceOracle = ICreditManagerV3(sharedInfo.creditManager).priceOracle(); + sharedInfo.quotaKeeper = IPoolV3(pool).poolQuotaKeeper(); + sharedInfo.underlyingPriceRAY = IPriceOracleV3(sharedInfo.priceOracle).convertToUSD(RAY, underlying); + } + + /// @dev Returns the token info needed for `creditAccount` collateral value calculation + /// @custom:tests U:[ALP-9] + function _getTokenInfo(address creditAccount, uint256 tokenMask, SharedInfo memory sharedInfo) + internal + view + returns (TokenInfo memory info) + { + (info.token, info.lt) = ICreditManagerV3(sharedInfo.creditManager).collateralTokenByMask(tokenMask); + if (info.lt == 0) return info; + + info.aliasParams = _aliasPriceFeedParams[info.token]; + if (info.aliasParams.priceFeed == address(0)) return info; + + info.balance = ERC20(info.token).balanceOf(creditAccount); + if (info.balance == 0) return info; + + (uint256 quota,) = IPoolQuotaKeeperV3(sharedInfo.quotaKeeper).getQuota(creditAccount, info.token); + info.quotaUSD = quota * sharedInfo.underlyingPriceRAY / RAY; + } + + /// @dev Returns the weighted value in USD (computed via either normal or alias price feed) for a single token + /// @custom:tests U:[ALP-10] + function _getWeightedValueUSD(TokenInfo memory tokenInfo, SharedInfo memory sharedInfo, bool aliased) + internal + view + returns (uint256) + { + uint256 valueUSD = aliased + ? _convertToUSDAlias(tokenInfo.aliasParams, tokenInfo.balance) + : IPriceOracleV3(sharedInfo.priceOracle).convertToUSD(tokenInfo.balance, tokenInfo.token); + + return Math.min(valueUSD * tokenInfo.lt / PERCENTAGE_FACTOR, tokenInfo.quotaUSD); + } + + /// @dev Converts token amount to USD using its alias price feed + /// @custom:tests U:[ALP-11] + function _convertToUSDAlias(PriceFeedParams memory aliasParams, uint256 amount) internal view returns (uint256) { + int256 answer = _getValidatedPrice(aliasParams.priceFeed, aliasParams.stalenessPeriod, aliasParams.skipCheck); + return uint256(answer) * amount / (10 ** aliasParams.tokenDecimals); + } +} diff --git a/contracts/credit/CreditFacadeV3.sol b/contracts/credit/CreditFacadeV3.sol index 38da83b4..871530a2 100644 --- a/contracts/credit/CreditFacadeV3.sol +++ b/contracts/credit/CreditFacadeV3.sol @@ -324,7 +324,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)) { + ILossPolicy.Params memory params = ILossPolicy.Params({ + totalDebtUSD: collateralDebtData.totalDebtUSD, + twvUSD: collateralDebtData.twvUSD, + extraData: lossPolicyData + }); + if (!ILossPolicy(lossPolicy).isLiquidatable(creditAccount, msg.sender, params)) { revert CreditAccountNotLiquidatableWithLossException(); // U:[FA-17] } maxDebtPerBlockMultiplier = 0; // U:[FA-17] diff --git a/contracts/interfaces/IAliasedLossPolicyV3.sol b/contracts/interfaces/IAliasedLossPolicyV3.sol new file mode 100644 index 00000000..01a86523 --- /dev/null +++ b/contracts/interfaces/IAliasedLossPolicyV3.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {IACLTrait} from "./base/IACLTrait.sol"; +import {ILossPolicy} from "./base/ILossPolicy.sol"; +import {PriceFeedParams} from "./IPriceOracleV3.sol"; + +interface IAliasedLossPolicyV3Events { + event SetAliasPriceFeed(address indexed token, address indexed priceFeed, uint32 stalenessPeriod, bool skipCheck); + event UnsetAliasPriceFeed(address indexed token); +} + +/// @title Aliased loss policy v3 interface +interface IAliasedLossPolicyV3 is IAliasedLossPolicyV3Events, ILossPolicy, IACLTrait { + // ------- // + // GETTERS // + // ------- // + + function pool() external view returns (address); + function underlying() external view returns (address); + function priceFeedStore() external view returns (address); + function getTokensWithAlias() external view returns (address[] memory); + function getAliasPriceFeedParams(address token) external view returns (PriceFeedParams memory); + function getRequiredAliasPriceFeeds(address creditAccount) external view returns (address[] memory); + + // ------------- // + // CONFIGURATION // + // ------------- // + + function setAliasPriceFeed(address token, address priceFeed) external; +} diff --git a/contracts/interfaces/base/ILossPolicy.sol b/contracts/interfaces/base/ILossPolicy.sol index 640d97d6..8c48ab20 100644 --- a/contracts/interfaces/base/ILossPolicy.sol +++ b/contracts/interfaces/base/ILossPolicy.sol @@ -7,19 +7,44 @@ import {IVersion} from "./IVersion.sol"; import {IStateSerializer} from "./IStateSerializer.sol"; /// @title Loss policy interface -/// @notice Generic interface for a loss policy contract that dictates 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. +/// @notice Generic interface for a loss policy that dictates conditions under which a bad debt liquidation can proceed. /// @dev Loss policies must have type `LOSS_POLICY::{POSTFIX}` interface ILossPolicy is IVersion, IStateSerializer { - /// @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 Parameters passed to the loss policy + /// @param totalDebtUSD Account's total debt in USD + /// @param twvUSD Account's total weighted value in USD + /// @param extraData Optional field that can be used to pass some off-chain data specific to implementation + struct Params { + uint256 totalDebtUSD; + uint256 twvUSD; + bytes extraData; + } - /// @notice Emergency function which forces `isLiquidatable` to always return `false` - function disable() external; + /// @notice Access mode for loss liquidations + enum AccessMode { + Permissionless, + Permissioned, + Forbidden + } - /// @notice Emergency function which forces `isLiquidatable` to always return `true` - function enable() external; + /// @notice Emitted when the loss policy access mode is set + event SetAccessMode(AccessMode mode); + + /// @notice Emitted when the loss policy checks are enabled or disabled + event SetChecksEnabled(bool enabled); + + /// @notice Whether `creditAccount` can be liquidated with loss by `caller` + function isLiquidatable(address creditAccount, address caller, Params calldata params) external returns (bool); + + /// @notice Returns current access mode + function accessMode() external view returns (AccessMode); + + /// @notice Returns whether policy checks are enabled + function checksEnabled() external view returns (bool); + + /// @notice Sets access mode for loss liquidations + function setAccessMode(AccessMode mode) external; + + /// @notice Enables or disables policy checks + function setChecksEnabled(bool enabled) external; } diff --git a/contracts/test/mocks/core/LossPolicyMock.sol b/contracts/test/mocks/core/LossPolicyMock.sol index e3c719cd..10961ec8 100644 --- a/contracts/test/mocks/core/LossPolicyMock.sol +++ b/contracts/test/mocks/core/LossPolicyMock.sol @@ -9,19 +9,28 @@ contract LossPolicyMock is ILossPolicy { uint256 public constant override version = 3_10; bytes32 public constant override contractType = "LOSS_POLICY::MOCK"; - bool public enabled = true; + AccessMode public override accessMode; + bool public override checksEnabled; - function serialize() external view override returns (bytes memory) {} + bool public isLiquidatableResult = true; - function isLiquidatable(address, address, bytes calldata) external view override returns (bool) { - return enabled; + function serialize() external pure override returns (bytes memory) { + return ""; } - function enable() external override { - enabled = true; + function isLiquidatable(address, address, Params calldata) external view override returns (bool) { + return isLiquidatableResult; } - function disable() external override { - enabled = false; + function setAccessMode(AccessMode mode) external override { + accessMode = mode; + } + + function setChecksEnabled(bool enabled) external override { + checksEnabled = enabled; + } + + function setIsLiquidatableResult(bool result) external { + isLiquidatableResult = result; } } diff --git a/contracts/test/mocks/oracles/PriceOracleMock.sol b/contracts/test/mocks/oracles/PriceOracleMock.sol index 00bcc394..032c9b29 100644 --- a/contracts/test/mocks/oracles/PriceOracleMock.sol +++ b/contracts/test/mocks/oracles/PriceOracleMock.sol @@ -44,6 +44,7 @@ contract PriceOracleMock is Test { /// @param amount Amount to convert /// @param token Address of the token to be converted function convertToUSD(uint256 amount, address token) public view returns (uint256) { + // FIXME: wrong formula, must use 10 ** token.decimals() return amount * getPrice(token) / 10 ** 8; } @@ -51,6 +52,7 @@ contract PriceOracleMock is Test { /// @param amount Amount to convert /// @param token Address of the token converted to function convertFromUSD(uint256 amount, address token) public view returns (uint256) { + // FIXME: wrong formula, must use 10 ** token.decimals() return amount * 10 ** 8 / getPrice(token); } diff --git a/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol b/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol new file mode 100644 index 00000000..703b68d8 --- /dev/null +++ b/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol @@ -0,0 +1,550 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ILossPolicy} from "../../../interfaces/base/ILossPolicy.sol"; +import {IPriceFeedStore, PriceUpdate} from "../../../interfaces/base/IPriceFeedStore.sol"; +import {IPriceOracleV3} from "../../../interfaces/IPriceOracleV3.sol"; +import {IAliasedLossPolicyV3Events} from "../../../interfaces/IAliasedLossPolicyV3.sol"; +import {PriceFeedParams} from "../../../interfaces/IPriceOracleV3.sol"; +import {ICreditManagerV3} from "../../../interfaces/ICreditManagerV3.sol"; +import {ICreditAccountV3} from "../../../interfaces/ICreditAccountV3.sol"; +import {IPoolQuotaKeeperV3} from "../../../interfaces/IPoolQuotaKeeperV3.sol"; +import {TokenIsNotQuotedException, CallerNotConfiguratorException} from "../../../interfaces/IExceptions.sol"; + +import {ERC20Mock} from "../../mocks/token/ERC20Mock.sol"; +import {PoolMock} from "../../mocks/pool/PoolMock.sol"; +import {PoolQuotaKeeperMock} from "../../mocks/pool/PoolQuotaKeeperMock.sol"; +import {PriceFeedMock} from "../../mocks/oracles/PriceFeedMock.sol"; +import {PriceFeedStoreMock} from "../../mocks/oracles/PriceFeedStoreMock.sol"; +import {PriceOracleMock} from "../../mocks/oracles/PriceOracleMock.sol"; +import { + AddressProviderV3ACLMock, + AP_PRICE_FEED_STORE, + NO_VERSION_CONTROL +} from "../../mocks/core/AddressProviderV3ACLMock.sol"; + +import {AliasedLossPolicyV3} from "../../../core/AliasedLossPolicyV3.sol"; +import {AliasedLossPolicyV3Harness} from "./AliasedLossPolicyV3Harness.sol"; + +/// @title Aliased Loss Policy V3 unit test +/// @notice U:[ALP]: Unit tests for aliased loss policy +contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { + event SetAccessMode(ILossPolicy.AccessMode mode); + event SetChecksEnabled(bool enabled); + + AliasedLossPolicyV3Harness lossPolicy; + + address configurator; + address caller; + + ERC20Mock underlying; + ERC20Mock token; + PriceFeedMock priceFeed; + + AddressProviderV3ACLMock addressProviderMock; + PriceFeedStoreMock priceFeedStoreMock; + PoolMock poolMock; + PoolQuotaKeeperMock poolQuotaKeeperMock; + PriceOracleMock priceOracleMock; + + address creditAccount; + address creditManager; + + function setUp() public { + configurator = makeAddr("CONFIGURATOR"); + caller = makeAddr("CALLER"); + + underlying = new ERC20Mock("Test Underlying", "TEST_UNDERLYING", 18); + token = new ERC20Mock("Test Token", "TEST", 18); + priceFeed = new PriceFeedMock(1e8, 8); + + vm.prank(configurator); + addressProviderMock = new AddressProviderV3ACLMock(); + priceFeedStoreMock = + PriceFeedStoreMock(addressProviderMock.getAddressOrRevert(AP_PRICE_FEED_STORE, NO_VERSION_CONTROL)); + + poolMock = new PoolMock(address(addressProviderMock), address(underlying)); + poolQuotaKeeperMock = new PoolQuotaKeeperMock(address(poolMock), address(underlying)); + poolMock.setPoolQuotaKeeper(address(poolQuotaKeeperMock)); + + priceOracleMock = new PriceOracleMock(); + priceOracleMock.setPrice(address(underlying), 10e8); + priceOracleMock.setPrice(address(token), 0.9e8); + + lossPolicy = new AliasedLossPolicyV3Harness(address(poolMock), address(addressProviderMock)); + + creditAccount = makeAddr("CREDIT_ACCOUNT"); + creditManager = makeAddr("CREDIT_MANAGER"); + vm.mockCall(creditAccount, abi.encodeCall(ICreditAccountV3.creditManager, ()), abi.encode(creditManager)); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.enabledTokensMaskOf, (creditAccount)), abi.encode(1)); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.priceOracle, ()), abi.encode(priceOracleMock)); + } + + /// @notice U:[ALP-1]: Constructor works correctly + function test_U_ALP_01_constructor_works_correctly() public view { + assertEq(lossPolicy.pool(), address(poolMock), "Incorrect pool"); + assertEq(lossPolicy.underlying(), address(underlying), "Incorrect underlying"); + assertEq(lossPolicy.priceFeedStore(), address(priceFeedStoreMock), "Incorrect priceFeedStore"); + assertEq( + uint256(lossPolicy.accessMode()), uint256(ILossPolicy.AccessMode.Permissionless), "Incorrect initial mode" + ); + assertTrue(lossPolicy.checksEnabled(), "Checks should be enabled initially"); + + // Check serialization + (ILossPolicy.AccessMode mode, bool checks, address[] memory tokens, PriceFeedParams[] memory params) = + abi.decode(lossPolicy.serialize(), (ILossPolicy.AccessMode, bool, address[], PriceFeedParams[])); + assertEq(uint256(mode), uint256(ILossPolicy.AccessMode.Permissionless), "Incorrect serialized mode"); + assertTrue(checks, "Incorrect serialized checks"); + assertEq(tokens.length, 0, "Incorrect serialized tokens length"); + assertEq(params.length, 0, "Incorrect serialized params length"); + } + + /// @notice U:[ALP-2]: `setAccessMode` and `setChecksEnabled` work correctly + function test_U_ALP_02_setAccessMode_and_setChecksEnabled_work_correctly() public { + // reverts on non-configurator + vm.expectRevert(CallerNotConfiguratorException.selector); + lossPolicy.setAccessMode(ILossPolicy.AccessMode.Forbidden); + + vm.expectRevert(CallerNotConfiguratorException.selector); + lossPolicy.setChecksEnabled(false); + + // setAccessMode works + vm.expectEmit(true, true, true, true); + emit SetAccessMode(ILossPolicy.AccessMode.Forbidden); + + vm.prank(configurator); + lossPolicy.setAccessMode(ILossPolicy.AccessMode.Forbidden); + assertEq(uint256(lossPolicy.accessMode()), uint256(ILossPolicy.AccessMode.Forbidden)); + + // setChecksEnabled works + vm.expectEmit(true, true, true, true); + emit SetChecksEnabled(false); + + vm.prank(configurator); + lossPolicy.setChecksEnabled(false); + assertFalse(lossPolicy.checksEnabled()); + } + + /// @notice U:[ALP-3]: `setAliasPriceFeed` works correctly + function test_U_ALP_03_setAliasPriceFeed_works_correctly() public { + // reverts on non-configurator + vm.expectRevert(CallerNotConfiguratorException.selector); + lossPolicy.setAliasPriceFeed(address(token), address(priceFeed)); + + // reverts if token is not quoted + poolQuotaKeeperMock.set_isQuotedToken(false); + + vm.expectRevert(TokenIsNotQuotedException.selector); + vm.prank(configurator); + lossPolicy.setAliasPriceFeed(address(token), address(priceFeed)); + + // sets price feed correctly + poolQuotaKeeperMock.set_isQuotedToken(true); + priceFeedStoreMock.setStalenessPeriod(address(priceFeed), 3600); + + vm.expectEmit(true, true, true, true); + emit SetAliasPriceFeed(address(token), address(priceFeed), 3600, false); + + vm.prank(configurator); + lossPolicy.setAliasPriceFeed(address(token), address(priceFeed)); + + PriceFeedParams memory params = lossPolicy.getAliasPriceFeedParams(address(token)); + assertEq(params.priceFeed, address(priceFeed), "Incorrect priceFeed"); + assertEq(params.stalenessPeriod, 3600, "Incorrect stalenessPeriod"); + assertEq(params.skipCheck, false, "Incorrect skipCheck"); + assertEq(params.tokenDecimals, 18, "Incorrect tokenDecimals"); + + address[] memory tokens = lossPolicy.getTokensWithAlias(); + assertEq(tokens.length, 1, "Incorrect number of tokens"); + assertEq(tokens[0], address(token), "Incorrect token"); + + // Check serialization after setting price feed + ( + ILossPolicy.AccessMode mode, + bool checks, + address[] memory serializedTokens, + PriceFeedParams[] memory serializedParams + ) = abi.decode(lossPolicy.serialize(), (ILossPolicy.AccessMode, bool, address[], PriceFeedParams[])); + assertEq(uint256(mode), uint256(ILossPolicy.AccessMode.Permissionless), "Incorrect serialized mode"); + assertTrue(checks, "Incorrect serialized checks"); + assertEq(serializedTokens.length, 1, "Incorrect serialized tokens length"); + assertEq(serializedTokens[0], address(token), "Incorrect serialized token"); + assertEq(serializedParams.length, 1, "Incorrect serialized params length"); + assertEq(serializedParams[0].priceFeed, address(priceFeed), "Incorrect serialized priceFeed"); + assertEq(serializedParams[0].stalenessPeriod, 3600, "Incorrect serialized stalenessPeriod"); + assertEq(serializedParams[0].skipCheck, false, "Incorrect serialized skipCheck"); + assertEq(serializedParams[0].tokenDecimals, 18, "Incorrect serialized tokenDecimals"); + + // unsets price feed correctly + vm.expectEmit(true, true, true, true); + emit UnsetAliasPriceFeed(address(token)); + + vm.prank(configurator); + lossPolicy.setAliasPriceFeed(address(token), address(0)); + + params = lossPolicy.getAliasPriceFeedParams(address(token)); + assertEq(params.priceFeed, address(0), "Price feed not unset"); + + tokens = lossPolicy.getTokensWithAlias(); + assertEq(tokens.length, 0, "Token not removed from set"); + + // Check serialization after unsetting price feed + (mode, checks, serializedTokens, serializedParams) = + abi.decode(lossPolicy.serialize(), (ILossPolicy.AccessMode, bool, address[], PriceFeedParams[])); + assertEq(uint256(mode), uint256(ILossPolicy.AccessMode.Permissionless), "Incorrect serialized mode"); + assertTrue(checks, "Incorrect serialized checks"); + assertEq(serializedTokens.length, 0, "Incorrect serialized tokens length"); + assertEq(serializedParams.length, 0, "Incorrect serialized params length"); + } + + /// @notice U:[ALP-4]: `isLiquidatable` works correctly in different modes + function test_U_ALP_04_isLiquidatable_works_correctly_in_different_modes() public { + address caller2 = makeAddr("CALLER2"); + addressProviderMock.grantRole("LOSS_LIQUIDATOR", caller2); + + ILossPolicy.Params memory liquidatableParams = + ILossPolicy.Params({totalDebtUSD: 1e8, twvUSD: 0.99e8, extraData: ""}); + ILossPolicy.Params memory nonLiquidatableParams = + ILossPolicy.Params({totalDebtUSD: 1e8, twvUSD: 1.01e8, extraData: ""}); + + // Permissionless mode with checks enabled + lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Permissionless); + lossPolicy.hackChecksEnabled(true); + + assertTrue( + lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + "permissionless + checks, liquidatable account" + ); + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller, nonLiquidatableParams), + "permissionless + checks, non-liquidatable account" + ); + + // Permissionless mode with checks disabled + lossPolicy.hackChecksEnabled(false); + + assertTrue( + lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + "permissionless + no checks, liquidatable account" + ); + assertTrue( + lossPolicy.isLiquidatable(creditAccount, caller, nonLiquidatableParams), + "permissionless + no checks, non-liquidatable account" + ); + + // Permissioned mode with checks enabled + lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Permissioned); + lossPolicy.hackChecksEnabled(true); + + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + "permissioned + checks, non-whitelisted caller, liquidatable account" + ); + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller2, nonLiquidatableParams), + "permissioned + checks, whitelisted caller, non-liquidatable account" + ); + assertTrue( + lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), + "permissioned + checks, whitelisted caller, liquidatable account" + ); + + // Permissioned mode with checks disabled + lossPolicy.hackChecksEnabled(false); + + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + "permissioned + no checks, non-whitelisted caller" + ); + assertTrue( + lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), + "permissioned + no checks, whitelisted caller" + ); + + // Forbidden mode (checks don't matter) + lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Forbidden); + + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), "forbidden, non-whitelisted caller" + ); + assertFalse( + lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), "forbidden, whitelisted caller" + ); + } + + /// @notice U:[ALP-5]: `isLiquidatable` calls `updatePrices` if needed + function test_U_ALP_05_isLiquidatable_calls_updatePrices_if_needed() public { + lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Permissionless); + lossPolicy.hackChecksEnabled(true); + + PriceUpdate[] memory updates = new PriceUpdate[](0); + ILossPolicy.Params memory params = + ILossPolicy.Params({totalDebtUSD: 1e8, twvUSD: 0.99e8, extraData: abi.encode(updates)}); + + vm.expectCall(address(priceFeedStoreMock), abi.encodeCall(IPriceFeedStore.updatePrices, (updates)), 1); + lossPolicy.isLiquidatable(creditAccount, caller, params); + + params.extraData = ""; + + vm.expectCall(address(priceFeedStoreMock), abi.encodePacked(IPriceFeedStore.updatePrices.selector), 0); + lossPolicy.isLiquidatable(creditAccount, caller, params); + } + + /// @notice U:[ALP-6]: `getRequiredAliasPriceFeeds` works correctly + function test_U_ALP_06_getRequiredAliasPriceFeeds_works_correctly() public { + ERC20Mock token1 = new ERC20Mock("Test Token 1", "TEST1", 18); + ERC20Mock token2 = new ERC20Mock("Test Token 2", "TEST2", 18); + + address priceFeed1 = makeAddr("PRICE_FEED1"); + address priceFeed2 = makeAddr("PRICE_FEED2"); + + vm.mockCall( + creditManager, abi.encodeCall(ICreditManagerV3.getTokenByMask, (1)), abi.encode(address(underlying)) + ); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.getTokenByMask, (2)), abi.encode(token1)); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.getTokenByMask, (4)), abi.encode(token2)); + + lossPolicy.hackAddTokenWithAlias( + address(token1), + PriceFeedParams({priceFeed: priceFeed1, stalenessPeriod: 3600, skipCheck: false, tokenDecimals: 18}) + ); + lossPolicy.hackAddTokenWithAlias( + address(token2), + PriceFeedParams({priceFeed: priceFeed2, stalenessPeriod: 3600, skipCheck: false, tokenDecimals: 18}) + ); + + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.enabledTokensMaskOf, (creditAccount)), abi.encode(3)); + + address[] memory priceFeeds = lossPolicy.getRequiredAliasPriceFeeds(creditAccount); + assertEq(priceFeeds.length, 1, "Incorrect number of price feeds"); + assertEq(priceFeeds[0], priceFeed1, "Incorrect first price feed"); + + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.enabledTokensMaskOf, (creditAccount)), abi.encode(6)); + priceFeeds = lossPolicy.getRequiredAliasPriceFeeds(creditAccount); + assertEq(priceFeeds.length, 2, "Incorrect number of price feeds"); + assertEq(priceFeeds[0], priceFeed1, "Incorrect first price feed"); + assertEq(priceFeeds[1], priceFeed2, "Incorrect second price feed"); + } + + /// @notice U:[ALP-7]: `_adjustForAliases` works correctly + function test_U_ALP_07_adjustForAliases_works_correctly() public { + ERC20Mock token2 = new ERC20Mock("Test Token 2", "TEST2", 18); + + // Setup mocks for two tokens + vm.mockCall( + creditManager, + abi.encodeCall(ICreditManagerV3.enabledTokensMaskOf, (creditAccount)), + abi.encode(6) // Tokens with masks 2 and 4 (mask 1 is for underlying) + ); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.getTokenByMask, (2)), abi.encode(address(token))); + vm.mockCall(creditManager, abi.encodeCall(ICreditManagerV3.getTokenByMask, (4)), abi.encode(address(token2))); + vm.mockCall( + creditManager, + abi.encodeCall(ICreditManagerV3.collateralTokenByMask, (2)), + abi.encode(address(token), uint16(9000)) + ); + vm.mockCall( + creditManager, + abi.encodeCall(ICreditManagerV3.collateralTokenByMask, (4)), + abi.encode(address(token2), uint16(9000)) + ); + + // Setup token balances and quotas + token.mint(creditAccount, 1e18); + token2.mint(creditAccount, 1e18); + vm.mockCall( + address(poolQuotaKeeperMock), + abi.encodeCall(IPoolQuotaKeeperV3.getQuota, (creditAccount, address(token))), + abi.encode(2e18, 0) + ); + vm.mockCall( + address(poolQuotaKeeperMock), + abi.encodeCall(IPoolQuotaKeeperV3.getQuota, (creditAccount, address(token2))), + abi.encode(2e18, 0) + ); + + vm.mockCall( + address(priceOracleMock), + abi.encodeCall(IPriceOracleV3.convertToUSD, (1e27, address(underlying))), + abi.encode(1e18) + ); + vm.mockCall( + address(priceOracleMock), + abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token))), + abi.encode(0.9e8) + ); + vm.mockCall( + address(priceOracleMock), + abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token2))), + abi.encode(0.8e8) + ); + + // Add alias price feed for token1 only + lossPolicy.hackAddTokenWithAlias( + address(token), + PriceFeedParams({priceFeed: address(priceFeed), stalenessPeriod: 3600, skipCheck: false, tokenDecimals: 18}) + ); + + // For token1: + // - Normal price: 0.9e8 * 90% = 0.81e8 + // - Alias price: 1e8 * 90% = 0.9e8 + // - Difference: +0.09e8 + // + // For token2: + // - Normal price: 0.8e8 * 90% = 0.72e8 + // - No alias price + // - Difference: 0 + // + // Initial TWV = 1.53e8 (0.81e8 + 0.72e8) + // Adjustment = 0.09e8 (from token1) + // Final TWV = 1.53e8 + 0.09e8 = 1.62e8 + uint256 initialTWV = 1.53e8; + uint256 adjustedTWV = lossPolicy.exposed_adjustForAliases(creditAccount, initialTWV); + assertEq(adjustedTWV, initialTWV + 0.09e8, "Incorrect adjusted TWV"); + } + + /// @notice U:[ALP-8]: `_getSharedInfo` works correctly + function test_U_ALP_08_getSharedInfo_works_correctly() public { + vm.mockCall( + address(priceOracleMock), + abi.encodeCall(IPriceOracleV3.convertToUSD, (1e27, address(underlying))), + abi.encode(1e18) + ); + + AliasedLossPolicyV3.SharedInfo memory info = lossPolicy.exposed_getSharedInfo(creditAccount); + assertEq(info.creditManager, creditManager, "Incorrect creditManager"); + assertEq(info.priceOracle, address(priceOracleMock), "Incorrect priceOracle"); + assertEq(info.quotaKeeper, address(poolQuotaKeeperMock), "Incorrect quotaKeeper"); + assertEq(info.underlyingPriceRAY, 1e18, "Incorrect underlyingPriceRAY"); + } + + /// @notice U:[ALP-9]: `_getTokenInfo` works correctly + function test_U_ALP_09_getTokenInfo_works_correctly() public { + AliasedLossPolicyV3.SharedInfo memory sharedInfo = AliasedLossPolicyV3.SharedInfo({ + creditManager: creditManager, + priceOracle: address(priceOracleMock), + quotaKeeper: address(poolQuotaKeeperMock), + underlyingPriceRAY: 1e18 + }); + + uint256 tokenMask = 2; + + // Returns empty info if token has LT = 0 + vm.mockCall( + creditManager, + abi.encodeCall(ICreditManagerV3.collateralTokenByMask, (tokenMask)), + abi.encode(token, uint16(0)) + ); + AliasedLossPolicyV3.TokenInfo memory info = + lossPolicy.exposed_getTokenInfo(creditAccount, tokenMask, sharedInfo); + assertEq(info.token, address(token), "Incorrect token"); + assertEq(info.lt, 0, "LT should be 0"); + assertEq(info.balance, 0, "Balance should be 0"); + assertEq(info.quotaUSD, 0, "QuotaUSD should be 0"); + assertEq(info.aliasParams.priceFeed, address(0), "Alias price feed should be 0"); + + // Returns empty info if token has no alias + vm.mockCall( + creditManager, + abi.encodeCall(ICreditManagerV3.collateralTokenByMask, (tokenMask)), + abi.encode(token, uint16(9000)) + ); + info = lossPolicy.exposed_getTokenInfo(creditAccount, tokenMask, sharedInfo); + assertEq(info.token, address(token), "Incorrect token"); + assertEq(info.lt, 9000, "Incorrect LT"); + assertEq(info.balance, 0, "Balance should be 0"); + assertEq(info.quotaUSD, 0, "QuotaUSD should be 0"); + assertEq(info.aliasParams.priceFeed, address(0), "Alias price feed should be 0"); + + // Returns empty info if token has no balance + lossPolicy.hackAddTokenWithAlias( + address(token), + PriceFeedParams({priceFeed: address(priceFeed), stalenessPeriod: 3600, skipCheck: false, tokenDecimals: 18}) + ); + info = lossPolicy.exposed_getTokenInfo(creditAccount, tokenMask, sharedInfo); + assertEq(info.token, address(token), "Incorrect token"); + assertEq(info.lt, 9000, "Incorrect LT"); + assertEq(info.balance, 0, "Balance should be 0"); + assertEq(info.quotaUSD, 0, "QuotaUSD should be 0"); + assertEq(info.aliasParams.priceFeed, address(priceFeed), "Alias price feed should be set"); + + // Returns empty info if token has no quota + token.mint(creditAccount, 1e18); + info = lossPolicy.exposed_getTokenInfo(creditAccount, tokenMask, sharedInfo); + assertEq(info.token, address(token), "Incorrect token"); + assertEq(info.lt, 9000, "Incorrect LT"); + assertEq(info.balance, 1e18, "Incorrect balance"); + assertEq(info.quotaUSD, 0, "QuotaUSD should be 0"); + assertEq(info.aliasParams.priceFeed, address(priceFeed), "Alias price feed should be set"); + + // Returns full info when all conditions are met + vm.mockCall( + address(poolQuotaKeeperMock), + abi.encodeCall(IPoolQuotaKeeperV3.getQuota, (creditAccount, address(token))), + abi.encode(0.1e18, 0) + ); + info = lossPolicy.exposed_getTokenInfo(creditAccount, tokenMask, sharedInfo); + assertEq(info.token, address(token), "Incorrect token"); + assertEq(info.lt, 9000, "Incorrect LT"); + assertEq(info.balance, 1e18, "Incorrect balance"); + assertEq(info.quotaUSD, 1e8, "Incorrect quotaUSD"); + assertEq(info.aliasParams.priceFeed, address(priceFeed), "Alias price feed should be set"); + } + + /// @notice U:[ALP-10]: `_getWeightedValueUSD` works correctly + function test_U_ALP_10_getWeightedValueUSD_works_correctly() public { + vm.mockCall( + address(priceOracleMock), + abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token))), + abi.encode(0.9e8) + ); + + AliasedLossPolicyV3.SharedInfo memory sharedInfo = AliasedLossPolicyV3.SharedInfo({ + creditManager: creditManager, + priceOracle: address(priceOracleMock), + quotaKeeper: address(poolQuotaKeeperMock), + underlyingPriceRAY: 1e18 + }); + + AliasedLossPolicyV3.TokenInfo memory tokenInfo = AliasedLossPolicyV3.TokenInfo({ + token: address(token), + lt: 9000, + balance: 1e18, + quotaUSD: 1e8, + aliasParams: PriceFeedParams({ + priceFeed: address(priceFeed), + stalenessPeriod: 3600, + skipCheck: false, + tokenDecimals: 18 + }) + }); + + // Normal price + uint256 weightedValue = lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, false); + assertEq(weightedValue, 0.81e8, "Incorrect weighted value"); // min(0.9e18 * 90%, 1e8) + + // Alias price + weightedValue = lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, true); + assertEq(weightedValue, 0.9e8, "Incorrect weighted value aliased"); // min(1e8 * 90%, 1e8) + } + + /// @notice U:[ALP-11]: `_convertToUSDAlias` works correctly + function test_U_ALP_11_convertToUSDAlias_works_correctly() public view { + PriceFeedParams memory params = + PriceFeedParams({priceFeed: address(priceFeed), stalenessPeriod: 3600, skipCheck: false, tokenDecimals: 18}); + + // Normal case + uint256 usdValue = lossPolicy.exposed_convertToUSDAlias(params, 1e18); + assertEq(usdValue, 1e8, "Incorrect USD value"); + + // Different token decimals + params.tokenDecimals = 6; + usdValue = lossPolicy.exposed_convertToUSDAlias(params, 1e6); + assertEq(usdValue, 1e8, "Incorrect USD value"); + } +} diff --git a/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol b/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol new file mode 100644 index 00000000..8f1f3e69 --- /dev/null +++ b/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2024. +pragma solidity ^0.8.17; + +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {AliasedLossPolicyV3} from "../../../core/AliasedLossPolicyV3.sol"; +import {PriceFeedParams} from "../../../interfaces/IPriceOracleV3.sol"; + +/// @title Aliased Loss Policy V3 Harness +/// @notice Exposes internal functions for testing +contract AliasedLossPolicyV3Harness is AliasedLossPolicyV3 { + using EnumerableSet for EnumerableSet.AddressSet; + + constructor(address pool_, address addressProvider_) AliasedLossPolicyV3(pool_, addressProvider_) {} + + function exposed_adjustForAliases(address creditAccount, uint256 twvUSD) external view returns (uint256) { + return _adjustForAliases(creditAccount, twvUSD); + } + + function exposed_getSharedInfo(address creditAccount) external view returns (SharedInfo memory) { + return _getSharedInfo(creditAccount); + } + + function exposed_getTokenInfo(address creditAccount, uint256 tokenMask, SharedInfo memory sharedInfo) + external + view + returns (TokenInfo memory) + { + return _getTokenInfo(creditAccount, tokenMask, sharedInfo); + } + + function exposed_getWeightedValueUSD(TokenInfo memory tokenInfo, SharedInfo memory sharedInfo, bool aliased) + external + view + returns (uint256) + { + return _getWeightedValueUSD(tokenInfo, sharedInfo, aliased); + } + + function exposed_convertToUSDAlias(PriceFeedParams memory aliasParams, uint256 amount) + external + view + returns (uint256) + { + return _convertToUSDAlias(aliasParams, amount); + } + + function hackAccessMode(AccessMode mode) external { + accessMode = mode; + } + + function hackChecksEnabled(bool enabled) external { + checksEnabled = enabled; + } + + function hackAddTokenWithAlias(address token, PriceFeedParams memory params) external { + _tokensWithAliasSet.add(token); + _aliasPriceFeedParams[token] = params; + } +} diff --git a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol index e873a4dc..0dedafe0 100644 --- a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol @@ -41,7 +41,7 @@ import {AllowanceAction} from "../../../interfaces/ICreditConfiguratorV3.sol"; import {IBotListV3} from "../../../interfaces/IBotListV3.sol"; import {IPriceFeedStore, PriceUpdate} from "../../../interfaces/base/IPriceFeedStore.sol"; import {IUpdatablePriceFeed} from "../../../interfaces/base/IPriceFeed.sol"; - +import {ILossPolicy} from "../../../interfaces/base/ILossPolicy.sol"; import {BitMask} from "../../../libraries/BitMask.sol"; import {BalanceWithMask} from "../../../libraries/BalancesLogic.sol"; import {MultiCallBuilder} from "../../lib/MultiCallBuilder.sol"; @@ -627,6 +627,14 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve abi.encodeCall(ICreditManagerV3.calcDebtAndCollateral, (creditAccount, CollateralCalcTask.DEBT_COLLATERAL)) ); + vm.expectCall( + address(lossPolicyMock), + abi.encodeCall( + ILossPolicy.isLiquidatable, + (creditAccount, LIQUIDATOR, ILossPolicy.Params({totalDebtUSD: 101, twvUSD: 100, extraData: ""})) + ) + ); + CollateralDebtData memory collateralDebtDataAfter = collateralDebtData; collateralDebtDataAfter.enabledTokensMask = 1 | 2 | 4; vm.expectCall( @@ -962,13 +970,13 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditManagerMock.setDebtAndCollateralData(collateralDebtData); // reverts if loss policy is violated - lossPolicyMock.disable(); + lossPolicyMock.setIsLiquidatableResult(false); vm.expectRevert(CreditAccountNotLiquidatableWithLossException.selector); vm.prank(FRIEND); creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); // if loss policy is not violated, further borrowing is forbidden - lossPolicyMock.enable(); + lossPolicyMock.setIsLiquidatableResult(true); vm.prank(LIQUIDATOR); creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); assertEq(creditFacade.maxDebtPerBlockMultiplier(), 0, "Borrowing not forbidden"); From fe1554f214906c92dce696d8993cb7a9d34db46c Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Wed, 19 Feb 2025 20:59:29 +0200 Subject: [PATCH 3/3] fix: nits --- contracts/core/AliasedLossPolicyV3.sol | 16 +++-- contracts/credit/CreditFacadeV3.sol | 2 +- contracts/interfaces/base/ILossPolicy.sol | 4 +- contracts/test/mocks/core/LossPolicyMock.sol | 10 +-- .../test/mocks/oracles/PriceOracleMock.sol | 13 ++-- .../unit/core/AliasedLossPolicyV3.unit.t.sol | 71 +++++++------------ .../unit/core/AliasedLossPolicyV3Harness.sol | 12 ++-- .../unit/credit/CreditFacadeV3.unit.t.sol | 6 +- .../unit/credit/CreditManagerV3.unit.t.sol | 28 ++++---- 9 files changed, 76 insertions(+), 86 deletions(-) diff --git a/contracts/core/AliasedLossPolicyV3.sol b/contracts/core/AliasedLossPolicyV3.sol index 12fc5bd4..ae266785 100644 --- a/contracts/core/AliasedLossPolicyV3.sol +++ b/contracts/core/AliasedLossPolicyV3.sol @@ -42,6 +42,12 @@ contract AliasedLossPolicyV3 is ACLTrait, PriceFeedValidationTrait, IAliasedLoss using EnumerableSet for EnumerableSet.AddressSet; using MarketHelper for IPoolV3; + /// @dev Internal enum with possible price feed types + enum PriceFeedType { + Normal, + Aliased + } + /// @dev Internal struct that contains shared info needed for collateral calculation struct SharedInfo { address creditManager; @@ -114,7 +120,7 @@ contract AliasedLossPolicyV3 is ACLTrait, PriceFeedValidationTrait, IAliasedLoss /// @notice Returns whether `creditAccount` can be liquidated with loss by `caller` /// @custom:tests U:[ALP-4], U:[ALP-5] - function isLiquidatable(address creditAccount, address caller, Params calldata params) + function isLiquidatableWithLoss(address creditAccount, address caller, Params calldata params) external override returns (bool) @@ -248,8 +254,8 @@ contract AliasedLossPolicyV3 is ACLTrait, PriceFeedValidationTrait, IAliasedLoss // no need to check other fields since `quotaUSD` is initialized only if all of them are non-zero if (tokenInfo.quotaUSD == 0) continue; - twvUSDAliased += _getWeightedValueUSD(tokenInfo, sharedInfo, true); - twvUSDAliased -= _getWeightedValueUSD(tokenInfo, sharedInfo, false); + twvUSDAliased += _getWeightedValueUSD(tokenInfo, sharedInfo, PriceFeedType.Aliased); + twvUSDAliased -= _getWeightedValueUSD(tokenInfo, sharedInfo, PriceFeedType.Normal); } } @@ -284,12 +290,12 @@ contract AliasedLossPolicyV3 is ACLTrait, PriceFeedValidationTrait, IAliasedLoss /// @dev Returns the weighted value in USD (computed via either normal or alias price feed) for a single token /// @custom:tests U:[ALP-10] - function _getWeightedValueUSD(TokenInfo memory tokenInfo, SharedInfo memory sharedInfo, bool aliased) + function _getWeightedValueUSD(TokenInfo memory tokenInfo, SharedInfo memory sharedInfo, PriceFeedType priceFeedType) internal view returns (uint256) { - uint256 valueUSD = aliased + uint256 valueUSD = priceFeedType == PriceFeedType.Aliased ? _convertToUSDAlias(tokenInfo.aliasParams, tokenInfo.balance) : IPriceOracleV3(sharedInfo.priceOracle).convertToUSD(tokenInfo.balance, tokenInfo.token); diff --git a/contracts/credit/CreditFacadeV3.sol b/contracts/credit/CreditFacadeV3.sol index 871530a2..1dac83f3 100644 --- a/contracts/credit/CreditFacadeV3.sol +++ b/contracts/credit/CreditFacadeV3.sol @@ -329,7 +329,7 @@ contract CreditFacadeV3 is ICreditFacadeV3, Pausable, ACLTrait, ReentrancyGuardT twvUSD: collateralDebtData.twvUSD, extraData: lossPolicyData }); - if (!ILossPolicy(lossPolicy).isLiquidatable(creditAccount, msg.sender, params)) { + if (!ILossPolicy(lossPolicy).isLiquidatableWithLoss(creditAccount, msg.sender, params)) { revert CreditAccountNotLiquidatableWithLossException(); // U:[FA-17] } maxDebtPerBlockMultiplier = 0; // U:[FA-17] diff --git a/contracts/interfaces/base/ILossPolicy.sol b/contracts/interfaces/base/ILossPolicy.sol index 8c48ab20..787aa824 100644 --- a/contracts/interfaces/base/ILossPolicy.sol +++ b/contracts/interfaces/base/ILossPolicy.sol @@ -34,7 +34,9 @@ interface ILossPolicy is IVersion, IStateSerializer { event SetChecksEnabled(bool enabled); /// @notice Whether `creditAccount` can be liquidated with loss by `caller` - function isLiquidatable(address creditAccount, address caller, Params calldata params) external returns (bool); + function isLiquidatableWithLoss(address creditAccount, address caller, Params calldata params) + external + returns (bool); /// @notice Returns current access mode function accessMode() external view returns (AccessMode); diff --git a/contracts/test/mocks/core/LossPolicyMock.sol b/contracts/test/mocks/core/LossPolicyMock.sol index 10961ec8..c915fed4 100644 --- a/contracts/test/mocks/core/LossPolicyMock.sol +++ b/contracts/test/mocks/core/LossPolicyMock.sol @@ -12,14 +12,14 @@ contract LossPolicyMock is ILossPolicy { AccessMode public override accessMode; bool public override checksEnabled; - bool public isLiquidatableResult = true; + bool public isLiquidatableWithLossResult = true; function serialize() external pure override returns (bytes memory) { return ""; } - function isLiquidatable(address, address, Params calldata) external view override returns (bool) { - return isLiquidatableResult; + function isLiquidatableWithLoss(address, address, Params calldata) external view override returns (bool) { + return isLiquidatableWithLossResult; } function setAccessMode(AccessMode mode) external override { @@ -30,7 +30,7 @@ contract LossPolicyMock is ILossPolicy { checksEnabled = enabled; } - function setIsLiquidatableResult(bool result) external { - isLiquidatableResult = result; + function setisLiquidatableWithLossResult(bool result) external { + isLiquidatableWithLossResult = result; } } diff --git a/contracts/test/mocks/oracles/PriceOracleMock.sol b/contracts/test/mocks/oracles/PriceOracleMock.sol index 032c9b29..7ec524c3 100644 --- a/contracts/test/mocks/oracles/PriceOracleMock.sol +++ b/contracts/test/mocks/oracles/PriceOracleMock.sol @@ -8,6 +8,7 @@ pragma solidity ^0.8.17; import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /// @title Disposable credit accounts factory contract PriceOracleMock is Test { @@ -44,16 +45,14 @@ contract PriceOracleMock is Test { /// @param amount Amount to convert /// @param token Address of the token to be converted function convertToUSD(uint256 amount, address token) public view returns (uint256) { - // FIXME: wrong formula, must use 10 ** token.decimals() - return amount * getPrice(token) / 10 ** 8; + return amount * getPrice(token) / _scale(token); } /// @dev Converts a quantity of USD (decimals = 8) to an equivalent amount of an asset /// @param amount Amount to convert /// @param token Address of the token converted to function convertFromUSD(uint256 amount, address token) public view returns (uint256) { - // FIXME: wrong formula, must use 10 ** token.decimals() - return amount * 10 ** 8 / getPrice(token); + return amount * _scale(token) / getPrice(token); } /// @dev Converts one asset into another @@ -62,7 +61,7 @@ contract PriceOracleMock is Test { /// @param tokenFrom Address of the token to convert from /// @param tokenTo Address of the token to convert to function convert(uint256 amount, address tokenFrom, address tokenTo) external view returns (uint256) { - return convertFromUSD(convertToUSD(amount, tokenFrom), tokenTo); + return amount * getPrice(tokenFrom) * _scale(tokenTo) / (getPrice(tokenTo) * _scale(tokenFrom)); } /// @dev Returns token's price in USD (8 decimals) @@ -83,4 +82,8 @@ contract PriceOracleMock is Test { priceFeed = priceFeedsInt[token][false]; require(priceFeed != address(0), "Price feed is not set"); } + + function _scale(address token) internal view returns (uint256) { + return 10 ** ERC20(token).decimals(); + } } diff --git a/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol b/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol index 703b68d8..1c9032a7 100644 --- a/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol +++ b/contracts/test/unit/core/AliasedLossPolicyV3.unit.t.sol @@ -202,8 +202,8 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { assertEq(serializedParams.length, 0, "Incorrect serialized params length"); } - /// @notice U:[ALP-4]: `isLiquidatable` works correctly in different modes - function test_U_ALP_04_isLiquidatable_works_correctly_in_different_modes() public { + /// @notice U:[ALP-4]: `isLiquidatableWithLoss` works correctly in different modes + function test_U_ALP_04_isLiquidatableWithLoss_works_correctly_in_different_modes() public { address caller2 = makeAddr("CALLER2"); addressProviderMock.grantRole("LOSS_LIQUIDATOR", caller2); @@ -217,11 +217,11 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { lossPolicy.hackChecksEnabled(true); assertTrue( - lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, liquidatableParams), "permissionless + checks, liquidatable account" ); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller, nonLiquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, nonLiquidatableParams), "permissionless + checks, non-liquidatable account" ); @@ -229,11 +229,11 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { lossPolicy.hackChecksEnabled(false); assertTrue( - lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, liquidatableParams), "permissionless + no checks, liquidatable account" ); assertTrue( - lossPolicy.isLiquidatable(creditAccount, caller, nonLiquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, nonLiquidatableParams), "permissionless + no checks, non-liquidatable account" ); @@ -242,15 +242,15 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { lossPolicy.hackChecksEnabled(true); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, liquidatableParams), "permissioned + checks, non-whitelisted caller, liquidatable account" ); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller2, nonLiquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller2, nonLiquidatableParams), "permissioned + checks, whitelisted caller, non-liquidatable account" ); assertTrue( - lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller2, liquidatableParams), "permissioned + checks, whitelisted caller, liquidatable account" ); @@ -258,11 +258,11 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { lossPolicy.hackChecksEnabled(false); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, liquidatableParams), "permissioned + no checks, non-whitelisted caller" ); assertTrue( - lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), + lossPolicy.isLiquidatableWithLoss(creditAccount, caller2, liquidatableParams), "permissioned + no checks, whitelisted caller" ); @@ -270,15 +270,17 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Forbidden); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller, liquidatableParams), "forbidden, non-whitelisted caller" + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, liquidatableParams), + "forbidden, non-whitelisted caller" ); assertFalse( - lossPolicy.isLiquidatable(creditAccount, caller2, liquidatableParams), "forbidden, whitelisted caller" + lossPolicy.isLiquidatableWithLoss(creditAccount, caller2, liquidatableParams), + "forbidden, whitelisted caller" ); } - /// @notice U:[ALP-5]: `isLiquidatable` calls `updatePrices` if needed - function test_U_ALP_05_isLiquidatable_calls_updatePrices_if_needed() public { + /// @notice U:[ALP-5]: `isLiquidatableWithLoss` calls `updatePrices` if needed + function test_U_ALP_05_isLiquidatableWithLoss_calls_updatePrices_if_needed() public { lossPolicy.hackAccessMode(ILossPolicy.AccessMode.Permissionless); lossPolicy.hackChecksEnabled(true); @@ -287,12 +289,12 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { ILossPolicy.Params({totalDebtUSD: 1e8, twvUSD: 0.99e8, extraData: abi.encode(updates)}); vm.expectCall(address(priceFeedStoreMock), abi.encodeCall(IPriceFeedStore.updatePrices, (updates)), 1); - lossPolicy.isLiquidatable(creditAccount, caller, params); + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, params); params.extraData = ""; vm.expectCall(address(priceFeedStoreMock), abi.encodePacked(IPriceFeedStore.updatePrices.selector), 0); - lossPolicy.isLiquidatable(creditAccount, caller, params); + lossPolicy.isLiquidatableWithLoss(creditAccount, caller, params); } /// @notice U:[ALP-6]: `getRequiredAliasPriceFeeds` works correctly @@ -334,6 +336,7 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { /// @notice U:[ALP-7]: `_adjustForAliases` works correctly function test_U_ALP_07_adjustForAliases_works_correctly() public { ERC20Mock token2 = new ERC20Mock("Test Token 2", "TEST2", 18); + priceOracleMock.setPrice(address(token2), 0.8e8); // Setup mocks for two tokens vm.mockCall( @@ -368,22 +371,6 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { abi.encode(2e18, 0) ); - vm.mockCall( - address(priceOracleMock), - abi.encodeCall(IPriceOracleV3.convertToUSD, (1e27, address(underlying))), - abi.encode(1e18) - ); - vm.mockCall( - address(priceOracleMock), - abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token))), - abi.encode(0.9e8) - ); - vm.mockCall( - address(priceOracleMock), - abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token2))), - abi.encode(0.8e8) - ); - // Add alias price feed for token1 only lossPolicy.hackAddTokenWithAlias( address(token), @@ -410,12 +397,6 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { /// @notice U:[ALP-8]: `_getSharedInfo` works correctly function test_U_ALP_08_getSharedInfo_works_correctly() public { - vm.mockCall( - address(priceOracleMock), - abi.encodeCall(IPriceOracleV3.convertToUSD, (1e27, address(underlying))), - abi.encode(1e18) - ); - AliasedLossPolicyV3.SharedInfo memory info = lossPolicy.exposed_getSharedInfo(creditAccount); assertEq(info.creditManager, creditManager, "Incorrect creditManager"); assertEq(info.priceOracle, address(priceOracleMock), "Incorrect priceOracle"); @@ -498,12 +479,6 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { /// @notice U:[ALP-10]: `_getWeightedValueUSD` works correctly function test_U_ALP_10_getWeightedValueUSD_works_correctly() public { - vm.mockCall( - address(priceOracleMock), - abi.encodeCall(IPriceOracleV3.convertToUSD, (1e18, address(token))), - abi.encode(0.9e8) - ); - AliasedLossPolicyV3.SharedInfo memory sharedInfo = AliasedLossPolicyV3.SharedInfo({ creditManager: creditManager, priceOracle: address(priceOracleMock), @@ -525,11 +500,13 @@ contract AliasedLossPolicyV3UnitTest is Test, IAliasedLossPolicyV3Events { }); // Normal price - uint256 weightedValue = lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, false); + uint256 weightedValue = + lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, AliasedLossPolicyV3.PriceFeedType.Normal); assertEq(weightedValue, 0.81e8, "Incorrect weighted value"); // min(0.9e18 * 90%, 1e8) // Alias price - weightedValue = lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, true); + weightedValue = + lossPolicy.exposed_getWeightedValueUSD(tokenInfo, sharedInfo, AliasedLossPolicyV3.PriceFeedType.Aliased); assertEq(weightedValue, 0.9e8, "Incorrect weighted value aliased"); // min(1e8 * 90%, 1e8) } diff --git a/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol b/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol index 8f1f3e69..dcb03ad0 100644 --- a/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol +++ b/contracts/test/unit/core/AliasedLossPolicyV3Harness.sol @@ -30,12 +30,12 @@ contract AliasedLossPolicyV3Harness is AliasedLossPolicyV3 { return _getTokenInfo(creditAccount, tokenMask, sharedInfo); } - function exposed_getWeightedValueUSD(TokenInfo memory tokenInfo, SharedInfo memory sharedInfo, bool aliased) - external - view - returns (uint256) - { - return _getWeightedValueUSD(tokenInfo, sharedInfo, aliased); + function exposed_getWeightedValueUSD( + TokenInfo memory tokenInfo, + SharedInfo memory sharedInfo, + PriceFeedType priceFeedType + ) external view returns (uint256) { + return _getWeightedValueUSD(tokenInfo, sharedInfo, priceFeedType); } function exposed_convertToUSDAlias(PriceFeedParams memory aliasParams, uint256 amount) diff --git a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol index 0dedafe0..353b4ac6 100644 --- a/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditFacadeV3.unit.t.sol @@ -630,7 +630,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve vm.expectCall( address(lossPolicyMock), abi.encodeCall( - ILossPolicy.isLiquidatable, + ILossPolicy.isLiquidatableWithLoss, (creditAccount, LIQUIDATOR, ILossPolicy.Params({totalDebtUSD: 101, twvUSD: 100, extraData: ""})) ) ); @@ -970,13 +970,13 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve creditManagerMock.setDebtAndCollateralData(collateralDebtData); // reverts if loss policy is violated - lossPolicyMock.setIsLiquidatableResult(false); + lossPolicyMock.setisLiquidatableWithLossResult(false); vm.expectRevert(CreditAccountNotLiquidatableWithLossException.selector); vm.prank(FRIEND); creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); // if loss policy is not violated, further borrowing is forbidden - lossPolicyMock.setIsLiquidatableResult(true); + lossPolicyMock.setisLiquidatableWithLossResult(true); vm.prank(LIQUIDATOR); creditFacade.liquidateCreditAccount({creditAccount: creditAccount, to: FRIEND, calls: new MultiCall[](0)}); assertEq(creditFacade.maxDebtPerBlockMultiplier(), 0, "Borrowing not forbidden"); diff --git a/contracts/test/unit/credit/CreditManagerV3.unit.t.sol b/contracts/test/unit/credit/CreditManagerV3.unit.t.sol index d748b8f0..89cfa20c 100644 --- a/contracts/test/unit/credit/CreditManagerV3.unit.t.sol +++ b/contracts/test/unit/credit/CreditManagerV3.unit.t.sol @@ -1439,7 +1439,8 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH uint256 enabledTokensMask, uint8 numberOfTokens ) public withFeeTokenCase creditManagerTest { - amount = bound(amount, 1e4, 1e10 * 10 ** _decimals(underlying)); + uint256 scale = 10 ** _decimals(underlying); + amount = bound(amount, scale, 1e10 * scale); numberOfTokens = uint8(bound(numberOfTokens, 1, DEFAULT_MAX_ENABLED_TOKENS)); enabledTokensMask = bound(enabledTokensMask, 1, 2 ** numberOfTokens - 1); @@ -1466,7 +1467,7 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH vm.assume(cdd.twvUSD != 0); // makes account liquidatable - creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD + 1)); + creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD * scale / 1e8) * 101 / 100); assertTrue( creditManager.isLiquidatable(creditAccount, PERCENTAGE_FACTOR), @@ -1482,7 +1483,7 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH }); // makes account non-liquidatable - creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD - 1)); + creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD * scale / 1e8)); assertFalse( creditManager.isLiquidatable(creditAccount, PERCENTAGE_FACTOR), @@ -1544,7 +1545,8 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH uint256 healthFactor, uint16 minHealthFactor ) public withFeeTokenCase creditManagerTest { - amount = bound(amount, 1e4, 1e10 * 10 ** _decimals(underlying)); + uint256 scale = 10 ** _decimals(underlying); + amount = bound(amount, scale, 1e10 * scale); healthFactor = bound(healthFactor, 0.2 ether, 5 ether); // sets underlying price to 1 USD @@ -1565,7 +1567,7 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH }); CollateralDebtData memory cdd = creditManager.calcDebtAndCollateral(creditAccount, CollateralCalcTask.DEBT_COLLATERAL); - creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD * 1 ether / healthFactor)); + creditManager.setDebt(creditAccount, _amountMinusFee(cdd.twvUSD * scale * 1e10 / healthFactor)); bool liquidatable = healthFactor / 1e14 < minHealthFactor; assertEq(creditManager.isLiquidatable(creditAccount, minHealthFactor), liquidatable, "Incorrect isLiquidatable"); @@ -1757,7 +1759,7 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH }); /// @notice Quotas are nominated in underlying token, so we use underlying price instead link one - vars.set("LINK_QUOTA_IN_USD", vars.get("LINK_QUOTA") * vars.get("UNDERLYING_PRICE")); + vars.set("LINK_QUOTA_IN_USD", vars.get("LINK_QUOTA") * vars.get("UNDERLYING_PRICE") / 1e10); } /// @dev U:[CM-22]: calcDebtAndCollateral works correctly for DEBT_COLLATERAL* task @@ -1773,16 +1775,16 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH enabledTokensMask: UNDERLYING_TOKEN_MASK, underlyingBalance: debt, linkBalance: 0, - expectedTotalValueUSD: vars.get("UNDERLYING_PRICE") * debt, - expectedTwvUSD: vars.get("UNDERLYING_PRICE") * debt * DEFAULT_UNDERLYING_LT / PERCENTAGE_FACTOR + expectedTotalValueUSD: vars.get("UNDERLYING_PRICE") * debt / 1e10, + expectedTwvUSD: vars.get("UNDERLYING_PRICE") * debt * DEFAULT_UNDERLYING_LT / PERCENTAGE_FACTOR / 1e10 }), CollateralCalcTestCase({ name: "One quoted token with balance < quota", enabledTokensMask: LINK_TOKEN_MASK, underlyingBalance: 0, linkBalance: vars.get("LINK_QUOTA") / 2 / vars.get("LINK_PRICE"), - expectedTotalValueUSD: vars.get("LINK_QUOTA") / 2, - expectedTwvUSD: vars.get("LINK_QUOTA") / 2 * vars.get("LINK_LT") / PERCENTAGE_FACTOR + expectedTotalValueUSD: vars.get("LINK_QUOTA") / 2 / 1e10, + expectedTwvUSD: vars.get("LINK_QUOTA") / 2 * vars.get("LINK_LT") / PERCENTAGE_FACTOR / 1e10 }), CollateralCalcTestCase({ name: "One quoted token with balance > quota", @@ -1827,7 +1829,7 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH assertEq( collateralDebtData.totalDebtUSD, vars.get("UNDERLYING_PRICE") - * (debt + collateralDebtData.accruedInterest + collateralDebtData.accruedFees), + * (debt + collateralDebtData.accruedInterest + collateralDebtData.accruedFees) / 1e10, _testCaseErr("Incorrect totalDebtUSD") ); @@ -1841,8 +1843,8 @@ contract CreditManagerV3UnitTest is TestHelper, ICreditManagerV3Events, BalanceH assertEq( collateralDebtData.totalValue, - _case.expectedTotalValueUSD / vars.get("UNDERLYING_PRICE"), - _testCaseErr("Incorrect totalValueUSD") + _case.expectedTotalValueUSD / vars.get("UNDERLYING_PRICE") * 1e10, + _testCaseErr("Incorrect totalValue") ); vm.revertTo(snapshot); }