diff --git a/contracts/ControllerTimelockV3.sol b/contracts/ControllerTimelockV3.sol new file mode 100644 index 0000000..36bcf43 --- /dev/null +++ b/contracts/ControllerTimelockV3.sol @@ -0,0 +1,644 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import {PolicyManagerV3} from "./PolicyManagerV3.sol"; +import {IControllerTimelockV3, QueuedTransactionData} from "./interfaces/IControllerTimelockV3.sol"; +import {ICreditManagerV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditManagerV3.sol"; +import {ICreditFacadeV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; +import {IPoolV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IPoolV3.sol"; +import {IPoolQuotaKeeperV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IPoolQuotaKeeperV3.sol"; +import {IGaugeV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IGaugeV3.sol"; +import {IPriceOracleV3, PriceFeedParams} from "@gearbox-protocol/core-v3/contracts/interfaces/IPriceOracleV3.sol"; +import {ILPPriceFeedV2} from "@gearbox-protocol/core-v2/contracts/interfaces/ILPPriceFeedV2.sol"; + +/// @title Controller timelock V3 +/// @notice Controller timelock is a governance contract that allows special actors less trusted than Gearbox Governance +/// to modify system parameters within set boundaries. This is mostly related to risk parameters that should be +/// adjusted frequently or periodic tasks (e.g., updating price feed limiters) that are too trivial to employ +/// the full governance for. +/// @dev The contract uses `PolicyManager` as its underlying engine to set parameter change boundaries and conditions. +/// In order to schedule a change for a particular contract / function combination, a policy needs to be defined +/// for it. The policy also determines the address that can change a particular parameter. +contract ControllerTimelockV3 is PolicyManagerV3, IControllerTimelockV3 { + /// @notice Contract version + uint256 public constant override version = 3_1; + + /// @dev Minimum liquidation threshold ramp duration + uint256 constant MIN_LT_RAMP_DURATION = 7 days; + + /// @notice Period before a mature transaction becomes stale + uint256 public constant override GRACE_PERIOD = 14 days; + + /// @notice Admin address that can cancel transactions + address public override vetoAdmin; + + /// @notice Mapping from address to their status as executor + mapping(address => bool) public override isExecutor; + + /// @notice Mapping from transaction hashes to their data + mapping(bytes32 => QueuedTransactionData) public override queuedTransactions; + + /// @notice Constructor + /// @param _addressProvider Address of the address provider + /// @param _vetoAdmin Admin that can cancel transactions + constructor(address _addressProvider, address _vetoAdmin) PolicyManagerV3(_addressProvider) { + vetoAdmin = _vetoAdmin; + } + + /// @dev Ensures that function caller is the veto admin + modifier vetoAdminOnly() { + _revertIfCallerIsNotVetoAdmin(); + _; + } + + /// @dev Reverts if `msg.sender` is not the veto admin + function _revertIfCallerIsNotVetoAdmin() internal view { + if (msg.sender != vetoAdmin) { + revert CallerNotVetoAdminException(); + } + } + + // -------- // + // QUEUEING // + // -------- // + + /// @notice Queues a transaction to set a new expiration date in the Credit Facade + /// @dev Requires the policy for keccak(group(creditManager), "EXPIRATION_DATE") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the expiration date for + /// @param expirationDate The new expiration date + function setExpirationDate(address creditManager, uint40 expirationDate) external override { + address creditConfigurator = ICreditManagerV3(creditManager).creditConfigurator(); + IPoolV3 pool = IPoolV3(ICreditManagerV3(creditManager).pool()); + + if (!_checkPolicy("setExpirationDate", uint256(expirationDate))) { + revert ParameterChecksFailedException(); // U:[CT-1] + } + + uint256 totalBorrowed = pool.creditManagerBorrowed(address(creditManager)); + + if (totalBorrowed != 0) { + revert ParameterChecksFailedException(); // U:[CT-1] + } + + _queueTransaction({ + target: creditConfigurator, + signature: "setExpirationDate(uint40)", + data: abi.encode(expirationDate), + delay: _getPolicyDelay("setExpirationDate"), + sanityCheckCallData: abi.encodeCall(this.getExpirationDate, (creditManager)) + }); // U:[CT-1] + } + + /// @dev Retrieves current expiration date for a credit manager + function getExpirationDate(address creditManager) public view returns (uint40) { + return ICreditFacadeV3(ICreditManagerV3(creditManager).creditFacade()).expirationDate(); + } + + /// @notice Queues a transaction to set a new limiter value in a price feed + /// @dev Requires the policy for keccak(group(priceFeed), "LP_PRICE_FEED_LIMITER") to be enabled, + /// otherwise auto-fails the check + /// @param priceFeed The price feed to update the limiter in + /// @param lowerBound The new limiter lower bound value + function setLPPriceFeedLimiter(address priceFeed, uint256 lowerBound) external override { + if (!_checkPolicy("setLPPriceFeedLimiter", lowerBound)) { + revert ParameterChecksFailedException(); // U:[CT-2] + } + + _queueTransaction({ + target: priceFeed, + signature: "setLimiter(uint256)", + data: abi.encode(lowerBound), + delay: _getPolicyDelay("setLPPriceFeedLimiter"), + sanityCheckCallData: abi.encodeCall(this.getPriceFeedLowerBound, (priceFeed)) + }); // U:[CT-2] + } + + /// @dev Retrieves current lower bound for a price feed + function getPriceFeedLowerBound(address priceFeed) public view returns (uint256) { + return ILPPriceFeedV2(priceFeed).lowerBound(); + } + + /// @notice Queues a transaction to set a new max debt per block multiplier + /// @dev Requires the policy for keccak(group(creditManager), "MAX_DEBT_PER_BLOCK_MULTIPLIER") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the multiplier for + /// @param multiplier The new multiplier value + function setMaxDebtPerBlockMultiplier(address creditManager, uint8 multiplier) external override { + address creditConfigurator = ICreditManagerV3(creditManager).creditConfigurator(); + + address creditFacade = ICreditManagerV3(creditManager).creditFacade(); + + if (!_checkPolicy("setMaxDebtPerBlockMultiplier", uint256(multiplier))) { + revert ParameterChecksFailedException(); // U:[CT-3] + } + + _queueTransaction({ + target: creditConfigurator, + signature: "setMaxDebtPerBlockMultiplier(uint8)", + data: abi.encode(multiplier), + delay: _getPolicyDelay("setMaxDebtPerBlockMultiplier"), + sanityCheckCallData: abi.encodeCall(this.getMaxDebtPerBlockMultiplier, (creditManager)) + }); // U:[CT-3] + } + + /// @dev Retrieves current max debt per block multiplier for a Credit Facade + function getMaxDebtPerBlockMultiplier(address creditManager) public view returns (uint8) { + return ICreditFacadeV3(ICreditManagerV3(creditManager).creditFacade()).maxDebtPerBlockMultiplier(); + } + + /// @notice Queues a transaction to set a new min debt per account + /// @dev Requires the policy for keccak(group(creditManager), "MIN_DEBT") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the limits for + /// @param minDebt The new minimal debt amount + function setMinDebtLimit(address creditManager, uint128 minDebt) external override { + address creditConfigurator = ICreditManagerV3(creditManager).creditConfigurator(); + + if (!_checkPolicy("setMinDebtLimit", uint256(minDebt))) { + revert ParameterChecksFailedException(); // U:[CT-4A] + } + + _queueTransaction({ + target: creditConfigurator, + signature: "setMinDebtLimit(uint128)", + data: abi.encode(minDebt), + delay: _getPolicyDelay("setMinDebtLimit"), + sanityCheckCallData: abi.encodeCall(this.getMinDebtLimit, (creditManager)) + }); // U:[CT-4A] + } + + /// @dev Retrieves the current min debt limit for a Credit Manager + function getMinDebtLimit(address creditManager) public view returns (uint128) { + (uint128 minDebtCurrent,) = ICreditFacadeV3(ICreditManagerV3(creditManager).creditFacade()).debtLimits(); + return minDebtCurrent; + } + + /// @notice Queues a transaction to set a new max debt per account + /// @dev Requires the policy for keccak(group(creditManager), "MAX_DEBT") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the limits for + /// @param maxDebt The new maximal debt amount + function setMaxDebtLimit(address creditManager, uint128 maxDebt) external override { + address creditConfigurator = ICreditManagerV3(creditManager).creditConfigurator(); + + if (!_checkPolicy("setMaxDebtLimit", uint256(maxDebt))) { + revert ParameterChecksFailedException(); // U:[CT-4B] + } + + _queueTransaction({ + target: creditConfigurator, + signature: "setMaxDebtLimit(uint128)", + data: abi.encode(maxDebt), + delay: _getPolicyDelay("setMaxDebtLimit"), + sanityCheckCallData: abi.encodeCall(this.getMaxDebtLimit, (creditManager)) + }); // U:[CT-4B] + } + + /// @dev Retrieves the current max debt limit for a Credit Manager + function getMaxDebtLimit(address creditManager) public view returns (uint128) { + (, uint128 maxDebtCurrent) = ICreditFacadeV3(ICreditManagerV3(creditManager).creditFacade()).debtLimits(); + return maxDebtCurrent; + } + + /// @notice Queues a transaction to set a new debt limit for a Credit Manager + /// @dev Requires the policy for keccak(group(creditManager), "CREDIT_MANAGER_DEBT_LIMIT") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the debt limit for + /// @param debtLimit The new debt limit + function setCreditManagerDebtLimit(address creditManager, uint256 debtLimit) external override { + IPoolV3 pool = IPoolV3(ICreditManagerV3(creditManager).pool()); + + if (!_checkPolicy("setCreditManagerDebtLimit", uint256(debtLimit))) { + revert ParameterChecksFailedException(); // U:[CT-5] + } + + _queueTransaction({ + target: address(pool), + signature: "setCreditManagerDebtLimit(address,uint256)", + data: abi.encode(address(creditManager), debtLimit), + delay: _getPolicyDelay("setCreditManagerDebtLimit"), + sanityCheckCallData: abi.encodeCall(this.getCreditManagerDebtLimit, (address(pool), creditManager)) + }); // U:[CT-5] + } + + /// @dev Retrieves the current total debt limit for Credit Manager from its pool + function getCreditManagerDebtLimit(address pool, address creditManager) public view returns (uint256) { + return IPoolV3(pool).creditManagerDebtLimit(creditManager); + } + + /// @notice Queues a transaction to start a liquidation threshold ramp + /// @dev Requires the policy for keccak(group(creditManager), group(token), "TOKEN_LT") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to update the LT for + /// @param token Token to ramp the LT for + /// @param liquidationThresholdFinal The liquidation threshold value after the ramp + /// @param rampDuration Duration of the ramp + function rampLiquidationThreshold( + address creditManager, + address token, + uint16 liquidationThresholdFinal, + uint40 rampStart, + uint24 rampDuration + ) external override { + uint256 delay = _getPolicyDelay("rampLiquidationThreshold"); + + if ( + !_checkPolicy("rampLiquidationThreshold", uint256(liquidationThresholdFinal)) + || rampDuration < MIN_LT_RAMP_DURATION || rampStart < block.timestamp + delay + ) { + revert ParameterChecksFailedException(); // U: [CT-6] + } + + _queueTransaction({ + target: ICreditManagerV3(creditManager).creditConfigurator(), + signature: "rampLiquidationThreshold(address,uint16,uint40,uint24)", + data: abi.encode(token, liquidationThresholdFinal, rampStart, rampDuration), + delay: delay, + sanityCheckCallData: abi.encodeCall(this.getLTRampParamsHash, (creditManager, token)) + }); // U: [CT-6] + } + + /// @dev Retrives the keccak of liquidation threshold params for a token + function getLTRampParamsHash(address creditManager, address token) public view returns (bytes32) { + (uint16 ltInitial, uint16 ltFinal, uint40 timestampRampStart, uint24 rampDuration) = + ICreditManagerV3(creditManager).ltParams(token); + return keccak256(abi.encode(ltInitial, ltFinal, timestampRampStart, rampDuration)); + } + + /// @notice Queues a transaction to forbid a third party contract adapter + /// @dev Requires the policy for keccak(group(creditManager), "FORBID_ADAPTER") to be enabled, + /// otherwise auto-fails the check + /// @param creditManager Adress of CM to forbid an adapter for + /// @param adapter Address of adapter to forbid + function forbidAdapter(address creditManager, address adapter) external override { + address creditConfigurator = ICreditManagerV3(creditManager).creditConfigurator(); + + // For `forbidAdapter`, there is no value to modify + // A policy check simply verifies that this controller has access to the function in a given group + if (!_checkPolicy("forbidAdapter", 0)) { + revert ParameterChecksFailedException(); // U: [CT-10] + } + + _queueTransaction({ + target: creditConfigurator, + signature: "forbidAdapter(address)", + data: abi.encode(adapter), + delay: _getPolicyDelay("forbidAdapter"), + sanityCheckCallData: "" + }); // U: [CT-10] + } + + /// @notice Queues a transaction to set a new limit on quotas for particular pool and token + /// @dev Requires the policy for keccak(group(pool), group(token), "TOKEN_LIMIT") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param token Token to update the limit for + /// @param limit The new value of the limit + function setTokenLimit(address pool, address token, uint96 limit) external override { + address poolQuotaKeeper = IPoolV3(pool).poolQuotaKeeper(); + + if (!_checkPolicy("setTokenLimit", uint256(limit))) { + revert ParameterChecksFailedException(); // U: [CT-11] + } + + _queueTransaction({ + target: poolQuotaKeeper, + signature: "setTokenLimit(address,uint96)", + data: abi.encode(token, limit), + delay: _getPolicyDelay("setTokenLimit"), + sanityCheckCallData: abi.encodeCall(this.getTokenLimit, (poolQuotaKeeper, token)) + }); // U: [CT-11] + } + + /// @dev Retrieves the per-token quota limit from pool quota keeper + function getTokenLimit(address poolQuotaKeeper, address token) public view returns (uint96) { + (,,,, uint96 oldLimit,) = IPoolQuotaKeeperV3(poolQuotaKeeper).getTokenQuotaParams(token); + return oldLimit; + } + + /// @notice Queues a transaction to set a new quota increase (trading) fee for a particular pool and token + /// @dev Requires the policy for keccak(group(pool), group(token), "TOKEN_QUOTA_INCREASE_FEE") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param token Token to update the limit for + /// @param quotaIncreaseFee The new value of the fee in bp + function setTokenQuotaIncreaseFee(address pool, address token, uint16 quotaIncreaseFee) external override { + address poolQuotaKeeper = IPoolV3(pool).poolQuotaKeeper(); + + if (!_checkPolicy("setTokenQuotaIncreaseFee", uint256(quotaIncreaseFee))) { + revert ParameterChecksFailedException(); // U: [CT-12] + } + + _queueTransaction({ + target: poolQuotaKeeper, + signature: "setTokenQuotaIncreaseFee(address,uint16)", + data: abi.encode(token, quotaIncreaseFee), + delay: _getPolicyDelay("setTokenQuotaIncreaseFee"), + sanityCheckCallData: abi.encodeCall(this.getTokenQuotaIncreaseFee, (poolQuotaKeeper, token)) + }); // U: [CT-12] + } + + /// @dev Retrieves the quota increase fee for a token + function getTokenQuotaIncreaseFee(address poolQuotaKeeper, address token) public view returns (uint16) { + (,, uint16 quotaIncreaseFee,,,) = IPoolQuotaKeeperV3(poolQuotaKeeper).getTokenQuotaParams(token); + return quotaIncreaseFee; + } + + /// @notice Queues a transaction to set a new total debt limit for the entire pool + /// @dev Requires the policy for keccak(group(pool), "TOTAL_DEBT_LIMIT") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param newLimit The new value of the limit + function setTotalDebtLimit(address pool, uint256 newLimit) external override { + uint256 totalDebtLimitOld = getTotalDebtLimit(pool); + + if (!_checkPolicy("setTotalDebtLimit", uint256(newLimit))) { + revert ParameterChecksFailedException(); // U: [CT-13] + } + + _queueTransaction({ + target: pool, + signature: "setTotalDebtLimit(uint256)", + data: abi.encode(newLimit), + delay: _getPolicyDelay("setTotalDebtLimit"), + sanityCheckCallData: abi.encodeCall(this.getTotalDebtLimit, (pool)) + }); // U: [CT-13] + } + + /// @dev Retrieves the total debt limit for a pool + function getTotalDebtLimit(address pool) public view returns (uint256) { + return IPoolV3(pool).totalDebtLimit(); + } + + /// @notice Queues a transaction to set a new withdrawal fee in a pool + /// @dev Requires the policy for keccak(group(pool), "WITHDRAW_FEE") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param newFee The new value of the fee in bp + function setWithdrawFee(address pool, uint256 newFee) external override { + if (!_checkPolicy("setWithdrawFee", newFee)) { + revert ParameterChecksFailedException(); // U: [CT-14] + } + + _queueTransaction({ + target: pool, + signature: "setWithdrawFee(uint256)", + data: abi.encode(newFee), + delay: _getPolicyDelay("setWithdrawFee"), + sanityCheckCallData: abi.encodeCall(this.getWithdrawFee, (pool)) + }); // U: [CT-14] + } + + /// @dev Retrieves the withdrawal fee for a pool + function getWithdrawFee(address pool) public view returns (uint256) { + return IPoolV3(pool).withdrawFee(); + } + + /// @notice Queues a transaction to set a new minimal quota interest rate for particular pool and token + /// @dev Requires the policy for keccak(group(pool), group(token), "TOKEN_QUOTA_MIN_RATE") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param token Token to set the new fee for + /// @param rate The new minimal rate + function setMinQuotaRate(address pool, address token, uint16 rate) external override { + address poolQuotaKeeper = IPoolV3(pool).poolQuotaKeeper(); + address gauge = IPoolQuotaKeeperV3(poolQuotaKeeper).gauge(); + + if (!_checkPolicy("setMinQuotaRate", uint256(rate))) { + revert ParameterChecksFailedException(); // U: [CT-15A] + } + + _queueTransaction({ + target: gauge, + signature: "changeQuotaMinRate(address,uint16)", + data: abi.encode(token, rate), + delay: _getPolicyDelay("setMinQuotaRate"), + sanityCheckCallData: abi.encodeCall(this.getMinQuotaRate, (gauge, token)) + }); // U: [CT-15A] + } + + /// @dev Retrieves the current minimal quota rate for a token in a gauge + function getMinQuotaRate(address gauge, address token) public view returns (uint16) { + (uint16 minRate,,,) = IGaugeV3(gauge).quotaRateParams(token); + return minRate; + } + + /// @notice Queues a transaction to set a new maximal quota interest rate for particular pool and token + /// @dev Requires the policy for keccak(group(pool), group(token), "TOKEN_QUOTA_MAX_RATE") to be enabled, + /// otherwise auto-fails the check + /// @param pool Pool to update the limit for + /// @param token Token to set the new fee for + /// @param rate The new maximal rate + function setMaxQuotaRate(address pool, address token, uint16 rate) external override { + address poolQuotaKeeper = IPoolV3(pool).poolQuotaKeeper(); + address gauge = IPoolQuotaKeeperV3(poolQuotaKeeper).gauge(); + + uint16 maxRateCurrent = getMaxQuotaRate(gauge, token); + + if (!_checkPolicy("setMaxQuotaRate", uint256(rate))) { + revert ParameterChecksFailedException(); // U: [CT-15B] + } + + _queueTransaction({ + target: gauge, + signature: "changeQuotaMaxRate(address,uint16)", + data: abi.encode(token, rate), + delay: _getPolicyDelay("setMaxQuotaRate"), + sanityCheckCallData: abi.encodeCall(this.getMaxQuotaRate, (gauge, token)) + }); // U: [CT-15B] + } + + /// @dev Retrieves the current maximal quota rate for a token in a gauge + function getMaxQuotaRate(address gauge, address token) public view returns (uint16) { + (, uint16 maxRate,,) = IGaugeV3(gauge).quotaRateParams(token); + return maxRate; + } + + /// @notice Queues a transaction to forbid permissionless bounds update in an LP price feed + /// @dev Requires the policy for keccak(group(priceFeed), "UPDATE_BOUNDS_ALLOWED") to be enabled, + /// otherwise auto-fails the check + /// @param priceFeed The price feed to forbid bounds update for + function forbidBoundsUpdate(address priceFeed) external override { + if (!_checkPolicy("forbidBoundsUpdate", 0)) { + revert ParameterChecksFailedException(); // U:[CT-16] + } + + _queueTransaction({ + target: priceFeed, + signature: "forbidBoundsUpdate()", + data: "", + delay: _getPolicyDelay("forbidBoundsUpdate"), + sanityCheckCallData: "" + }); // U:[CT-16] + } + + /// @notice Queues a transaction to change a price feed for a token + /// @dev Requires the policy for keccak(group(priceOracle), group(token), "PRICE_FEED") to be enabled, + /// otherwise auto-fails the check + function setPriceFeed(address priceOracle, address token, address priceFeed, uint32 stalenessPeriod) + external + override + { + string memory policyID = string(abi.encodePacked("setPriceFeed_", Strings.toHexString(token))); + + uint256 priceFeedHash = uint256(keccak256(abi.encode(priceFeed, stalenessPeriod))); + + if (!_checkPolicy(policyID, uint256(priceFeedHash))) { + revert ParameterChecksFailedException(); + } + + _queueTransaction({ + target: priceOracle, + signature: "setPriceFeed(address,address,uint32)", + data: abi.encode(token, priceFeed, stalenessPeriod), + delay: _getPolicyDelay(policyID), + sanityCheckCallData: abi.encodeCall(this.getCurrentPriceFeedHash, (priceOracle, token)) + }); + } + + function getCurrentPriceFeedHash(address priceOracle, address token) public view returns (uint256) { + PriceFeedParams memory pfParams = IPriceOracleV3(priceOracle).priceFeedParams(token); + return uint256(keccak256(abi.encode(pfParams.priceFeed, pfParams.stalenessPeriod))); + } + + /// @dev Internal function that stores the transaction in the queued tx map + /// @param target The contract to call + /// @param signature The signature of the called function + /// @param data The call data + /// @return Hash of the queued transaction + function _queueTransaction( + address target, + string memory signature, + bytes memory data, + uint256 delay, + bytes memory sanityCheckCallData + ) internal returns (bytes32) { + uint256 eta = block.timestamp + delay; + + bytes32 txHash = keccak256(abi.encode(msg.sender, target, signature, data)); + uint256 sanityCheckValue; + + if (sanityCheckCallData.length != 0) { + (, bytes memory returndata) = address(this).staticcall(sanityCheckCallData); + sanityCheckValue = abi.decode(returndata, (uint256)); + } + + queuedTransactions[txHash] = QueuedTransactionData({ + queued: true, + initiator: msg.sender, + target: target, + eta: uint40(eta), + signature: signature, + data: data, + sanityCheckValue: sanityCheckValue, + sanityCheckCallData: sanityCheckCallData + }); + + emit QueueTransaction({ + txHash: txHash, + initiator: msg.sender, + target: target, + signature: signature, + data: data, + eta: uint40(eta) + }); + + return txHash; + } + + // --------- // + // EXECUTION // + // --------- // + + /// @notice Sets the transaction's queued status as false, effectively cancelling it + /// @param txHash Hash of the transaction to be cancelled + function cancelTransaction(bytes32 txHash) + external + override + vetoAdminOnly // U: [CT-7] + { + queuedTransactions[txHash].queued = false; + emit CancelTransaction(txHash); + } + + /// @notice Executes a queued transaction + /// @param txHash Hash of the transaction to be executed + function executeTransaction(bytes32 txHash) external override { + QueuedTransactionData memory qtd = queuedTransactions[txHash]; + + if (!qtd.queued) { + revert TxNotQueuedException(); // U: [CT-7] + } + + if (msg.sender != qtd.initiator && !isExecutor[msg.sender]) { + revert CallerNotExecutorException(); // U: [CT-9] + } + + address target = qtd.target; + uint40 eta = qtd.eta; + string memory signature = qtd.signature; + bytes memory data = qtd.data; + + if (block.timestamp < eta || block.timestamp > eta + GRACE_PERIOD) { + revert TxExecutedOutsideTimeWindowException(); // U: [CT-9] + } + + // In order to ensure that we do not accidentally override a change + // made by configurator or another admin, the current value of the parameter + // is compared to the value at the moment of tx being queued + if (qtd.sanityCheckCallData.length != 0) { + (, bytes memory returndata) = address(this).staticcall(qtd.sanityCheckCallData); + + if (abi.decode(returndata, (uint256)) != qtd.sanityCheckValue) { + revert ParameterChangedAfterQueuedTxException(); + } + } + + queuedTransactions[txHash].queued = false; + + bytes memory callData; + + if (bytes(signature).length == 0) { + callData = data; + } else { + callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data); + } + + (bool success,) = target.call(callData); + + if (!success) { + revert TxExecutionRevertedException(); // U: [CT-9] + } + + emit ExecuteTransaction(txHash); // U: [CT-9] + } + + // ------------- // + // CONFIGURATION // + // ------------- // + + /// @notice Sets a new veto admin address + function setVetoAdmin(address newAdmin) + external + override + configuratorOnly // U: [CT-8] + { + if (vetoAdmin != newAdmin) { + vetoAdmin = newAdmin; // U: [CT-8] + emit SetVetoAdmin(newAdmin); // U: [CT-8] + } + } + + /// @notice Changes status of an address as an executor + function setExecutor(address executorAddress, bool status) external override configuratorOnly { + if (isExecutor[executorAddress] != status) { + isExecutor[executorAddress] = status; + emit SetExecutor(executorAddress, status); + } + } +} diff --git a/contracts/EmergencyLiquidator.sol b/contracts/EmergencyLiquidator.sol new file mode 100644 index 0000000..59cccdd --- /dev/null +++ b/contracts/EmergencyLiquidator.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol"; +import {SafeERC20} from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol"; + +import {ACLNonReentrantTrait} from "@gearbox-protocol/core-v3/contracts/traits/ACLNonReentrantTrait.sol"; +import {ICreditManagerV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditManagerV3.sol"; +import {ICreditFacadeV3, MultiCall} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; +import {ICreditFacadeV3Multicall} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3Multicall.sol"; + +interface IEmergencyLiquidatorExceptions { + /// @dev Thrown when a non-whitelisted account attempts to liquidate an account during pause + error NonWhitelistedLiquidationDuringPauseException(); + + /// @dev Thrown when a non-whitelisted account attempts to liquidate an account with loss + error NonWhitelistedLiquidationWithLossException(); + + /// @dev Thrown when liquidation calls contain withdrawals to an address other than emergency liquidator contract + error WithdrawalToExternalAddressException(); + + /// @dev Thrown when a non-whitelisted address attempts to call an access-restricted function + error CallerNotWhitelistedException(); +} + +interface IEmergencyLiquidatorEvents { + /// @dev Emitted when a new account is added to / removed from the whitelist + event SetWhitelistedStatus(address indexed account, bool newStatus); + + /// @dev Emitted when liquidating during pause is allowed / disallowed + event SetWhitelistedOnlyDuringPause(bool newStatus); + + /// @dev Emitted when liquidating with loss is allowed / disallowed + event SetWhitelistedOnlyWithLoss(bool newStatus); +} + +contract EmergencyLiquidator is ACLNonReentrantTrait, IEmergencyLiquidatorExceptions, IEmergencyLiquidatorEvents { + using SafeERC20 for IERC20; + + /// @dev Thrown when the access-restricted function's caller is not treasury + error CallerNotTreasuryException(); + + /// @notice Whether the address is a trusted account capable of doing whitelist-only actions + mapping(address => bool) public isWhitelisted; + + /// @notice Whether the emergency liquidator currently allows anyone to liquidate during pause + /// or only whitelisted addresses + bool public whitelistedOnlyDuringPause; + + /// @notice Whether the emergency liquidator currently allows anyone to liquidate with loss or only + /// whitelisted addresses + bool public whitelistedOnlyWithLoss; + + constructor(address _addressProvider) ACLNonReentrantTrait(_addressProvider) {} + + modifier whitelistedOnly() { + if (!isWhitelisted[msg.sender]) revert CallerNotWhitelistedException(); + _; + } + + /// @dev Checks that the liquidation satisfies certain criteria if the account is not whitelisted, reverts if not: + /// - If the contract is paused, checks whether liquidations during pause are available to non-whitelisted accounts + /// - If the liquidation is lossy (detected by Credit Facade internal loss counter increasing), checks whether lossy liquidations are available + /// to non-whitelisted account + modifier checkWhitelistedActions(address creditFacade) { + if (isWhitelisted[msg.sender]) { + _; + } else { + if (Pausable(creditFacade).paused() && whitelistedOnlyDuringPause) { + revert NonWhitelistedLiquidationDuringPauseException(); + } + + uint128 cumulativeLossBefore; + + if (whitelistedOnlyWithLoss) { + cumulativeLossBefore = _cumulativeLoss(creditFacade); + } + + _; + + if (whitelistedOnlyWithLoss) { + uint128 cumulativeLossAfter = _cumulativeLoss(creditFacade); + + if (cumulativeLossAfter > cumulativeLossBefore) { + revert NonWhitelistedLiquidationWithLossException(); + } + } + } + } + + /// @dev Checks that all withdrawals are sent to this contract, reverts if not + modifier checkWithdrawalDestinations(address creditFacade, MultiCall[] calldata calls) { + _checkWithdrawalsDestination(creditFacade, calls); + _; + } + + /// @notice Liquidates a credit account, while checking restrictions on liquidations during pause (if any) + function liquidateCreditAccount(address creditFacade, address creditAccount, MultiCall[] calldata calls) + external + checkWithdrawalDestinations(creditFacade, calls) + checkWhitelistedActions(creditFacade) + { + ICreditFacadeV3(creditFacade).liquidateCreditAccount(creditAccount, address(this), calls); + } + + /// @notice Liquidates a credit account with max underlying approval, allowing to buy collateral with DAO funds + /// @dev Can be exploited by account owners when open to everyone, and thus is only allowed for whitelisted addresses + function liquidateCreditAccountWithApproval(address creditFacade, address creditAccount, MultiCall[] calldata calls) + external + checkWithdrawalDestinations(creditFacade, calls) + whitelistedOnly + { + address creditManager = ICreditFacadeV3(creditFacade).creditManager(); + address underlying = ICreditManagerV3(creditManager).underlying(); + + IERC20(underlying).forceApprove(creditManager, type(uint256).max); + ICreditFacadeV3(creditFacade).liquidateCreditAccount(creditAccount, address(this), calls); + IERC20(underlying).forceApprove(creditManager, 1); + } + + /// @dev Checks that the provided calldata has all withdrawals sent to this contract + function _checkWithdrawalsDestination(address creditFacade, MultiCall[] calldata calls) internal view { + uint256 len = calls.length; + + for (uint256 i = 0; i < len;) { + if ( + calls[i].target == creditFacade + && bytes4(calls[i].callData) == ICreditFacadeV3Multicall.withdrawCollateral.selector + ) { + (,, address to) = abi.decode(calls[i].callData[4:], (address, uint256, address)); + + if (to != address(this)) revert WithdrawalToExternalAddressException(); + } + + unchecked { + ++i; + } + } + } + + /// @dev Retrieves cumulative loss for a credit facade + function _cumulativeLoss(address creditFacade) internal view returns (uint128 cumulativeLoss) { + (cumulativeLoss,) = ICreditFacadeV3(creditFacade).lossParams(); + } + + /// @notice Sends funds accumulated from liquidations to a specified address + function withdrawFunds(address token, address to) external configuratorOnly { + uint256 bal = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransfer(to, bal); + } + + /// @notice Sets the status of an account as whitelisted + function setWhitelistedAccount(address account, bool newStatus) external configuratorOnly { + bool whitelistedStatus = isWhitelisted[account]; + + if (newStatus != whitelistedStatus) { + isWhitelisted[account] = newStatus; + emit SetWhitelistedStatus(account, newStatus); + } + } + + /// @notice Sets whether liquidations during pause are only allowed to whitelisted addresses + function setWhitelistedOnlyDuringPause(bool newStatus) external configuratorOnly { + bool currentStatus = whitelistedOnlyDuringPause; + + if (newStatus != currentStatus) { + whitelistedOnlyDuringPause = newStatus; + emit SetWhitelistedOnlyDuringPause(newStatus); + } + } + + /// @notice Sets whether liquidations with loss are only allowed to whitelisted addresses + function setWhitelistedOnlyWithLoss(bool newStatus) external configuratorOnly { + bool currentStatus = whitelistedOnlyWithLoss; + + if (newStatus != currentStatus) { + whitelistedOnlyWithLoss = newStatus; + emit SetWhitelistedOnlyWithLoss(newStatus); + } + } +} diff --git a/contracts/PolicyManagerV3.sol b/contracts/PolicyManagerV3.sol new file mode 100644 index 0000000..059a721 --- /dev/null +++ b/contracts/PolicyManagerV3.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {ACLNonReentrantTrait} from "@gearbox-protocol/core-v3/contracts/traits/ACLNonReentrantTrait.sol"; +import {PERCENTAGE_FACTOR} from "@gearbox-protocol/core-v2/contracts/libraries/Constants.sol"; + +struct Policy { + bool enabled; + address admin; + uint40 delay; + bool checkInterval; + bool checkSet; + uint256 intervalMinValue; + uint256 intervalMaxValue; + uint256[] setValues; +} + +/// @title Policy manager V3 +/// @dev A contract for managing bounds and conditions for mission-critical protocol params +abstract contract PolicyManagerV3 is ACLNonReentrantTrait { + /// @dev Mapping from group-derived key to policy + mapping(string => Policy) internal _policies; + + /// @notice Emitted when new policy is set + event SetPolicy(string indexed policyID, bool enabled); + + constructor(address _acl) ACLNonReentrantTrait(_acl) {} + + /// @notice Sets the params for a new or existing policy, using policy UID as key + /// @param policyID A unique identifier for a policy, generally, should be the signature of a method which uses the policy. + /// Can also in some cases need additional parameters to be concatenated + /// @param policyParams Policy parameters + function setPolicy(string calldata policyID, Policy memory policyParams) + external + configuratorOnly // U:[PM-1] + { + policyParams.enabled = true; // U:[PM-1] + _policies[policyID] = policyParams; // U:[PM-1] + emit SetPolicy({policyID: policyID, enabled: true}); // U:[PM-1] + } + + /// @notice Disables the policy which makes all requested checks for the passed policy hash to auto-fail + /// @param policyID A unique identifier for a policy + function disablePolicy(string calldata policyID) + public + configuratorOnly // U:[PM-2] + { + _policies[policyID].enabled = false; // U:[PM-2] + emit SetPolicy({policyID: policyID, enabled: false}); // U:[PM-2] + } + + /// @notice Retrieves policy from policy UID + function getPolicy(string calldata policyID) external view returns (Policy memory) { + return _policies[policyID]; // U:[PM-1] + } + + /// @dev Returns policy transaction delay, with policy retrieved based on contract and parameter name + function _getPolicyDelay(string memory policyID) internal view returns (uint256) { + return _policies[policyID].delay; + } + + /// @dev Performs parameter checks, with policy retrieved based on policy UID + function _checkPolicy(string memory policyID, uint256 newValue) internal returns (bool) { + Policy storage policy = _policies[policyID]; + + if (!policy.enabled) return false; // U:[PM-2] + + if (policy.admin != msg.sender) return false; // U: [PM-5] + + if (policy.checkInterval) { + if (newValue < policy.intervalMinValue || newValue > policy.intervalMaxValue) return false; // U: [PM-3] + } + + if (policy.checkSet) { + if (!_isIn(policy.setValues, newValue)) return false; // U: [PM-4] + } + + return true; + } + + /// @dev Returns whether the value is an element of `arr` + function _isIn(uint256[] memory arr, uint256 value) internal pure returns (bool) { + uint256 len = arr.length; + + for (uint256 i = 0; i < len;) { + if (value == arr[i]) return true; + + unchecked { + ++i; + } + } + + return false; + } +} diff --git a/contracts/factories/AdapterFactoryV3.sol b/contracts/factories/AdapterFactoryV3.sol index 0af9b0c..dffc64e 100644 --- a/contracts/factories/AdapterFactoryV3.sol +++ b/contracts/factories/AdapterFactoryV3.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; interface IAdapterDeployer { function deploy(address creditManager, address target, bytes calldata specificParams) external returns (address); diff --git a/contracts/factories/CreditFactoryV3.sol b/contracts/factories/CreditFactoryV3.sol index 96620ac..f3e2ac1 100644 --- a/contracts/factories/CreditFactoryV3.sol +++ b/contracts/factories/CreditFactoryV3.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; import {ICreditManagerV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditManagerV3.sol"; import {CreditManagerV3} from "@gearbox-protocol/core-v3/contracts/credit/CreditManagerV3.sol"; import {IBytecodeRepository} from "./IBytecodeRepository.sol"; diff --git a/contracts/factories/IBytecodeRepository.sol b/contracts/factories/IBytecodeRepository.sol index e63d04c..49efc69 100644 --- a/contracts/factories/IBytecodeRepository.sol +++ b/contracts/factories/IBytecodeRepository.sol @@ -3,7 +3,7 @@ // (c) Gearbox Foundation, 2024. pragma solidity ^0.8.17; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; interface IBytecodeRepositoryEvents {} diff --git a/contracts/factories/InterestModelFactory.sol b/contracts/factories/InterestModelFactory.sol index 496999f..c55a5a4 100644 --- a/contracts/factories/InterestModelFactory.sol +++ b/contracts/factories/InterestModelFactory.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.17; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; import {Create2} from "@openzeppelin/contracts/utils/Create2.sol"; contract InterestModelFactory is IVersion { diff --git a/contracts/factories/MarketConfiguratorFactoryV3.sol b/contracts/factories/MarketConfiguratorFactoryV3.sol index 1fd03e9..16454eb 100644 --- a/contracts/factories/MarketConfiguratorFactoryV3.sol +++ b/contracts/factories/MarketConfiguratorFactoryV3.sol @@ -9,7 +9,7 @@ import {MarketConfigurator} from "./MarketConfigurator.sol"; import {ACL} from "../primitives/ACL.sol"; import {ContractsRegister} from "../primitives/ContractsRegister.sol"; import {IAddressProviderV3} from "../interfaces/IAddressProviderV3.sol"; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; import {IBytecodeRepository} from "./IBytecodeRepository.sol"; import {AP_MARKET_CONFIGURATOR} from "./ContractLiterals.sol"; diff --git a/contracts/factories/PoolFactoryV3.sol b/contracts/factories/PoolFactoryV3.sol index 9787fcb..ecd2fb0 100644 --- a/contracts/factories/PoolFactoryV3.sol +++ b/contracts/factories/PoolFactoryV3.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; import {AbstractFactory} from "./AbstractFactory.sol"; import {AP_POOL, AP_POOL_QUOTA_KEEPER, AP_POOL_RATE_KEEPER, AP_DEGEN_NFT} from "./ContractLiterals.sol"; import {MarketConfigurator} from "./MarketConfigurator.sol"; diff --git a/contracts/interfaces/IACL.sol b/contracts/interfaces/IACL.sol index f2fc59f..5895646 100644 --- a/contracts/interfaces/IACL.sol +++ b/contracts/interfaces/IACL.sol @@ -3,7 +3,7 @@ // (c) Gearbox Foundation, 2024. pragma solidity ^0.8.17; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; interface IACLExceptions { /// @dev Thrown when attempting to delete an address from a set that is not a pausable admin diff --git a/contracts/interfaces/IAddressProviderV3.sol b/contracts/interfaces/IAddressProviderV3.sol index 65fe2b5..ac8b99d 100644 --- a/contracts/interfaces/IAddressProviderV3.sol +++ b/contracts/interfaces/IAddressProviderV3.sol @@ -3,7 +3,7 @@ // (c) Gearbox Foundation, 2024. pragma solidity ^0.8.17; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; uint256 constant NO_VERSION_CONTROL = 0; diff --git a/contracts/interfaces/IContractsRegister.sol b/contracts/interfaces/IContractsRegister.sol index 3721fa1..de5206f 100644 --- a/contracts/interfaces/IContractsRegister.sol +++ b/contracts/interfaces/IContractsRegister.sol @@ -3,7 +3,7 @@ // (c) Gearbox Foundation, 2024. pragma solidity ^0.8.17; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; /// @title Contracts register interface interface IContractsRegister is IVersion { diff --git a/contracts/interfaces/IControllerTimelockV3.sol b/contracts/interfaces/IControllerTimelockV3.sol new file mode 100644 index 0000000..ee2527d --- /dev/null +++ b/contracts/interfaces/IControllerTimelockV3.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; + +struct QueuedTransactionData { + bool queued; + address initiator; + address target; + uint40 eta; + string signature; + bytes data; + uint256 sanityCheckValue; + bytes sanityCheckCallData; +} + +interface IControllerTimelockV3Events { + /// @notice Emitted when the veto admin of the controller is updated + event SetVetoAdmin(address indexed newAdmin); + + /// @notice Emitted when an address' status as executor is changed + event SetExecutor(address indexed executor, bool status); + + /// @notice Emitted when a transaction is queued + event QueueTransaction( + bytes32 indexed txHash, address indexed initiator, address target, string signature, bytes data, uint40 eta + ); + + /// @notice Emitted when a transaction is executed + event ExecuteTransaction(bytes32 indexed txHash); + + /// @notice Emitted when a transaction is cancelled + event CancelTransaction(bytes32 indexed txHash); +} + +interface IControllerTimelockV3Exceptions { + /// @notice Thrown when the new parameter values do not satisfy required conditions + error ParameterChecksFailedException(); + + /// @notice Thrown when attempting to execute a non-queued transaction + error TxNotQueuedException(); + + /// @notice Thrown when attempting to execute a transaction that is either immature or stale + error TxExecutedOutsideTimeWindowException(); + + /// @notice Thrown when execution of a transaction fails + error TxExecutionRevertedException(); + + /// @notice Thrown when the value of a parameter on execution is different from the value on queue + error ParameterChangedAfterQueuedTxException(); + + /// @notice Thrown when an address that is not the designated executor attempts to execute a transaction + error CallerNotExecutorException(); + + /// @notice Thrown on attempting to call an access restricted function not as veto admin + error CallerNotVetoAdminException(); +} + +/// @title Controller timelock V3 interface +interface IControllerTimelockV3 is IControllerTimelockV3Events, IControllerTimelockV3Exceptions, IVersion { + // -------- // + // QUEUEING // + // -------- // + + function setExpirationDate(address creditManager, uint40 expirationDate) external; + + function setMaxDebtPerBlockMultiplier(address creditManager, uint8 multiplier) external; + + function setMinDebtLimit(address creditManager, uint128 minDebt) external; + + function setMaxDebtLimit(address creditManager, uint128 maxDebt) external; + + function setCreditManagerDebtLimit(address creditManager, uint256 debtLimit) external; + + function rampLiquidationThreshold( + address creditManager, + address token, + uint16 liquidationThresholdFinal, + uint40 rampStart, + uint24 rampDuration + ) external; + + function forbidAdapter(address creditManager, address adapter) external; + + function setTotalDebtLimit(address pool, uint256 newLimit) external; + + function setTokenLimit(address pool, address token, uint96 limit) external; + + function setTokenQuotaIncreaseFee(address pool, address token, uint16 quotaIncreaseFee) external; + + function setMinQuotaRate(address pool, address token, uint16 rate) external; + + function setMaxQuotaRate(address pool, address token, uint16 rate) external; + + function setWithdrawFee(address pool, uint256 newFee) external; + + function setLPPriceFeedLimiter(address priceFeed, uint256 lowerBound) external; + + function forbidBoundsUpdate(address priceFeed) external; + + function setPriceFeed(address priceOracle, address token, address priceFeed, uint32 stalenessPeriod) external; + + // --------- // + // EXECUTION // + // --------- // + + function GRACE_PERIOD() external view returns (uint256); + + function queuedTransactions(bytes32 txHash) + external + view + returns ( + bool queued, + address initiator, + address target, + uint40 eta, + string memory signature, + bytes memory data, + uint256 sanityCheckValue, + bytes memory sanityCheckCallData + ); + + function executeTransaction(bytes32 txHash) external; + + function cancelTransaction(bytes32 txHash) external; + + // ------------- // + // CONFIGURATION // + // ------------- // + + function vetoAdmin() external view returns (address); + + function isExecutor(address addr) external view returns (bool); + + function setVetoAdmin(address newAdmin) external; + + function setExecutor(address executor, bool status) external; +} diff --git a/contracts/interfaces/IMarketConfiguratorV3.sol b/contracts/interfaces/IMarketConfiguratorV3.sol index 1b6742b..fdb457f 100644 --- a/contracts/interfaces/IMarketConfiguratorV3.sol +++ b/contracts/interfaces/IMarketConfiguratorV3.sol @@ -3,7 +3,7 @@ // (c) Gearbox Foundation, 2024. pragma solidity ^0.8.17; -import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/IVersion.sol"; +import {IVersion} from "@gearbox-protocol/core-v2/contracts/interfaces/IVersion.sol"; interface IMarketConfiguratorV3 is IVersion { /// @notice Risc curator who manages these markets diff --git a/contracts/test/ControllerTimelockV3.unit.t.sol b/contracts/test/ControllerTimelockV3.unit.t.sol new file mode 100644 index 0000000..6cb3d2d --- /dev/null +++ b/contracts/test/ControllerTimelockV3.unit.t.sol @@ -0,0 +1,1616 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {ControllerTimelockV3} from "../ControllerTimelockV3.sol"; +import {Policy} from "../PolicyManagerV3.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import {GeneralMock} from "@gearbox-protocol/core-v3/contracts/test/mocks/GeneralMock.sol"; + +import {ICreditManagerV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditManagerV3.sol"; +import {ICreditFacadeV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; +import {ICreditConfiguratorV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditConfiguratorV3.sol"; +import {IPriceOracleV3, PriceFeedParams} from "@gearbox-protocol/core-v3/contracts/interfaces/IPriceOracleV3.sol"; +import {IPoolV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IPoolV3.sol"; +import {IPoolQuotaKeeperV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IPoolQuotaKeeperV3.sol"; +import {IGaugeV3} from "@gearbox-protocol/core-v3/contracts/interfaces/IGaugeV3.sol"; +import {PoolV3} from "@gearbox-protocol/core-v3/contracts/pool/PoolV3.sol"; +import {PoolQuotaKeeperV3} from "@gearbox-protocol/core-v3/contracts/pool/PoolQuotaKeeperV3.sol"; +import {GaugeV3} from "@gearbox-protocol/core-v3/contracts/pool/GaugeV3.sol"; +import {ILPPriceFeedV2} from "@gearbox-protocol/core-v2/contracts/interfaces/ILPPriceFeedV2.sol"; +import {IControllerTimelockV3Events} from "../interfaces/IControllerTimelockV3.sol"; +import "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +// TEST +import "@gearbox-protocol/core-v3/contracts/test/lib/constants.sol"; + +// MOCKS +import {AddressProviderV3ACLMock} from + "@gearbox-protocol/core-v3/contracts/test/mocks/core/AddressProviderV3ACLMock.sol"; + +contract ControllerTimelockV3UnitTest is Test, IControllerTimelockV3Events { + AddressProviderV3ACLMock public addressProvider; + + ControllerTimelockV3 public controllerTimelock; + + address admin; + address vetoAdmin; + + function setUp() public { + admin = makeAddr("ADMIN"); + vetoAdmin = makeAddr("VETO_ADMIN"); + + vm.prank(CONFIGURATOR); + addressProvider = new AddressProviderV3ACLMock(); + controllerTimelock = new ControllerTimelockV3(address(addressProvider), vetoAdmin); + } + + function _makeMocks() + internal + returns ( + address creditManager, + address creditFacade, + address creditConfigurator, + address pool, + address poolQuotaKeeper + ) + { + creditManager = address(new GeneralMock()); + creditFacade = address(new GeneralMock()); + creditConfigurator = address(new GeneralMock()); + pool = address(new GeneralMock()); + poolQuotaKeeper = address(new GeneralMock()); + + vm.mockCall( + creditManager, abi.encodeWithSelector(ICreditManagerV3.creditFacade.selector), abi.encode(creditFacade) + ); + + vm.mockCall( + creditManager, + abi.encodeWithSelector(ICreditManagerV3.creditConfigurator.selector), + abi.encode(creditConfigurator) + ); + + vm.mockCall(creditManager, abi.encodeWithSelector(ICreditManagerV3.pool.selector), abi.encode(pool)); + + vm.mockCall(pool, abi.encodeCall(IPoolV3.poolQuotaKeeper, ()), abi.encode(poolQuotaKeeper)); + + vm.label(creditManager, "CREDIT_MANAGER"); + vm.label(creditFacade, "CREDIT_FACADE"); + vm.label(creditConfigurator, "CREDIT_CONFIGURATOR"); + vm.label(pool, "POOL"); + vm.label(poolQuotaKeeper, "PQK"); + } + + /// + /// + /// TESTS + /// + /// + + /// @dev U:[CT-1]: setExpirationDate works correctly + function test_U_CT_01_setExpirationDate_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator, address pool,) = _makeMocks(); + + string memory policyID = "setExpirationDate"; + + uint256 initialExpirationDate = block.timestamp; + + vm.mockCall( + creditFacade, + abi.encodeWithSelector(ICreditFacadeV3.expirationDate.selector), + abi.encode(initialExpirationDate) + ); + + vm.mockCall( + pool, abi.encodeWithSelector(IPoolV3.creditManagerBorrowed.selector, creditManager), abi.encode(1234) + ); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = block.timestamp + 5; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 5)); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 5)); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 4)); + + // VERIFY THAT EXTRA CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 5)); + + vm.mockCall(pool, abi.encodeWithSelector(IPoolV3.creditManagerBorrowed.selector, creditManager), abi.encode(0)); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode(admin, creditConfigurator, "setExpirationDate(uint40)", abi.encode(block.timestamp + 5)) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "setExpirationDate(uint40)", + abi.encode(block.timestamp + 5), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 5)); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, initialExpirationDate, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getExpirationDate, (creditManager))); + + vm.expectCall( + creditConfigurator, + abi.encodeWithSelector(ICreditConfiguratorV3.setExpirationDate.selector, block.timestamp + 5) + ); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-2]: setLPPriceFeedLimiter works correctly + function test_U_CT_02_setLPPriceFeedLimiter_works_correctly() public { + address lpPriceFeed = address(new GeneralMock()); + + vm.mockCall(lpPriceFeed, abi.encodeWithSelector(ILPPriceFeedV2.lowerBound.selector), abi.encode(5)); + + string memory policyID = "setLPPriceFeedLimiter"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 7; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setLPPriceFeedLimiter(lpPriceFeed, 7); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setLPPriceFeedLimiter(lpPriceFeed, 7); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setLPPriceFeedLimiter(lpPriceFeed, 8); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, lpPriceFeed, "setLimiter(uint256)", abi.encode(7))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, admin, lpPriceFeed, "setLimiter(uint256)", abi.encode(7), uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setLPPriceFeedLimiter(lpPriceFeed, 7); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 5, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getPriceFeedLowerBound, (lpPriceFeed))); + + vm.expectCall(lpPriceFeed, abi.encodeWithSelector(ILPPriceFeedV2.setLimiter.selector, 7)); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-3]: setMaxDebtPerBlockMultiplier works correctly + function test_U_CT_03_setMaxDebtPerBlockMultiplier_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator,,) = _makeMocks(); + + string memory policyID = "setMaxDebtPerBlockMultiplier"; + + vm.mockCall( + creditFacade, abi.encodeWithSelector(ICreditFacadeV3.maxDebtPerBlockMultiplier.selector), abi.encode(3) + ); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 4; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 2 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxDebtPerBlockMultiplier(creditManager, 4); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setMaxDebtPerBlockMultiplier(creditManager, 4); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxDebtPerBlockMultiplier(creditManager, 5); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = + keccak256(abi.encode(admin, creditConfigurator, "setMaxDebtPerBlockMultiplier(uint8)", abi.encode(4))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "setMaxDebtPerBlockMultiplier(uint8)", + abi.encode(4), + uint40(block.timestamp + 2 days) + ); + + vm.prank(admin); + controllerTimelock.setMaxDebtPerBlockMultiplier(creditManager, 4); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 3, "Sanity check value written incorrectly"); + + assertEq( + sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getMaxDebtPerBlockMultiplier, (creditManager)) + ); + + vm.expectCall( + creditConfigurator, abi.encodeWithSelector(ICreditConfiguratorV3.setMaxDebtPerBlockMultiplier.selector, 4) + ); + + vm.warp(block.timestamp + 2 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-4A]: setMinDebtLimit works correctly + function test_U_CT_04A_setMinDebtLimit_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator,,) = _makeMocks(); + + string memory policyID = "setMinDebtLimit"; + + vm.mockCall(creditFacade, abi.encodeWithSelector(ICreditFacadeV3.debtLimits.selector), abi.encode(10, 20)); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 3 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMinDebtLimit(creditManager, 15); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setMinDebtLimit(creditManager, 15); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMinDebtLimit(creditManager, 5); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, creditConfigurator, "setMinDebtLimit(uint128)", abi.encode(15))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "setMinDebtLimit(uint128)", + abi.encode(15), + uint40(block.timestamp + 3 days) + ); + + vm.prank(admin); + controllerTimelock.setMinDebtLimit(creditManager, 15); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 10, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getMinDebtLimit, (creditManager))); + + vm.expectCall(creditConfigurator, abi.encodeWithSelector(ICreditConfiguratorV3.setMinDebtLimit.selector, 15)); + + vm.warp(block.timestamp + 3 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-4B]: setMaxDebtLimit works correctly + function test_U_CT_04B_setMaxDebtLimit_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator,,) = _makeMocks(); + + string memory policyID = "setMaxDebtLimit"; + + vm.mockCall(creditFacade, abi.encodeWithSelector(ICreditFacadeV3.debtLimits.selector), abi.encode(10, 20)); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 25; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxDebtLimit(creditManager, 25); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setMaxDebtLimit(creditManager, 25); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxDebtLimit(creditManager, 5); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, creditConfigurator, "setMaxDebtLimit(uint128)", abi.encode(25))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "setMaxDebtLimit(uint128)", + abi.encode(25), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setMaxDebtLimit(creditManager, 25); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 20, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getMaxDebtLimit, (creditManager))); + + vm.expectCall(creditConfigurator, abi.encodeWithSelector(ICreditConfiguratorV3.setMaxDebtLimit.selector, 25)); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-5]: setCreditManagerDebtLimit works correctly + function test_U_CT_05_setCreditManagerDebtLimit_works_correctly() public { + (address creditManager, /* address creditFacade */,, address pool,) = _makeMocks(); + + string memory policyID = "setCreditManagerDebtLimit"; + + vm.mockCall( + pool, abi.encodeWithSelector(IPoolV3.creditManagerDebtLimit.selector, creditManager), abi.encode(1e18) + ); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 2e18; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setCreditManagerDebtLimit(creditManager, 2e18); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setCreditManagerDebtLimit(creditManager, 2e18); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setCreditManagerDebtLimit(creditManager, 1e18); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode(admin, pool, "setCreditManagerDebtLimit(address,uint256)", abi.encode(creditManager, 2e18)) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + pool, + "setCreditManagerDebtLimit(address,uint256)", + abi.encode(creditManager, 2e18), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setCreditManagerDebtLimit(creditManager, 2e18); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 1e18, "Sanity check value written incorrectly"); + + assertEq( + sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getCreditManagerDebtLimit, (pool, creditManager)) + ); + + vm.expectCall(pool, abi.encodeWithSelector(PoolV3.setCreditManagerDebtLimit.selector, creditManager, 2e18)); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-6]: rampLiquidationThreshold works correctly + function test_U_CT_06_rampLiquidationThreshold_works_correctly() public { + (address creditManager,, address creditConfigurator,,) = _makeMocks(); + + address token = makeAddr("TOKEN"); + + string memory policyID = "rampLiquidationThreshold"; + + vm.mockCall( + creditManager, abi.encodeWithSelector(ICreditManagerV3.liquidationThresholds.selector), abi.encode(5000) + ); + + vm.mockCall( + creditManager, + abi.encodeWithSelector(ICreditManagerV3.ltParams.selector), + abi.encode(uint16(5000), uint16(5000), type(uint40).max, uint24(0)) + ); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 6000; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 6000, uint40(block.timestamp + 14 days), 7 days + ); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 6000, uint40(block.timestamp + 14 days), 7 days + ); + + // VERIFY THAT POLICY CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 5000, uint40(block.timestamp + 14 days), 7 days + ); + + // VERIFY THAT EXTRA CHECKS ARE PERFORMED + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 6000, uint40(block.timestamp + 14 days), 1 days + ); + + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 6000, uint40(block.timestamp + 1 days / 2), 7 days + ); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode( + admin, + creditConfigurator, + "rampLiquidationThreshold(address,uint16,uint40,uint24)", + abi.encode(token, 6000, block.timestamp + 14 days, 7 days) + ) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "rampLiquidationThreshold(address,uint16,uint40,uint24)", + abi.encode(token, 6000, block.timestamp + 14 days, 7 days), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.rampLiquidationThreshold( + creditManager, token, 6000, uint40(block.timestamp + 14 days), 7 days + ); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq( + sanityCheckValue, + uint256(keccak256(abi.encode(uint16(5000), uint16(5000), type(uint40).max, uint24(0)))), + "Sanity check value written incorrectly" + ); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getLTRampParamsHash, (creditManager, token))); + + vm.expectCall( + creditConfigurator, + abi.encodeWithSelector( + ICreditConfiguratorV3.rampLiquidationThreshold.selector, + token, + 6000, + uint40(block.timestamp + 14 days), + 7 days + ) + ); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-7]: cancelTransaction works correctly + function test_U_CT_07_cancelTransaction_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator, address pool,) = _makeMocks(); + + string memory policyID = "setExpirationDate"; + + vm.mockCall( + creditFacade, abi.encodeWithSelector(ICreditFacadeV3.expirationDate.selector), abi.encode(block.timestamp) + ); + + vm.mockCall(pool, abi.encodeWithSelector(IPoolV3.creditManagerBorrowed.selector, creditManager), abi.encode(0)); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = block.timestamp + 5; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode(admin, creditConfigurator, "setExpirationDate(uint40)", abi.encode(block.timestamp + 5)) + ); + + vm.prank(admin); + controllerTimelock.setExpirationDate(creditManager, uint40(block.timestamp + 5)); + + vm.expectRevert(CallerNotVetoAdminException.selector); + + vm.prank(admin); + controllerTimelock.cancelTransaction(txHash); + + vm.expectEmit(true, false, false, false); + emit CancelTransaction(txHash); + + vm.prank(vetoAdmin); + controllerTimelock.cancelTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after cancelling"); + + vm.expectRevert(TxNotQueuedException.selector); + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + } + + /// @dev U:[CT-8]: configuration functions work correctly + function test_U_CT_08_configuration_works_correctly() public { + vm.expectRevert(CallerNotConfiguratorException.selector); + vm.prank(USER); + controllerTimelock.setVetoAdmin(DUMB_ADDRESS); + + vm.expectEmit(true, false, false, false); + emit SetVetoAdmin(DUMB_ADDRESS); + + vm.prank(CONFIGURATOR); + controllerTimelock.setVetoAdmin(DUMB_ADDRESS); + + assertEq(controllerTimelock.vetoAdmin(), DUMB_ADDRESS, "Veto admin address was not set"); + } + + /// @dev U:[CT-9]: executeTransaction works correctly + function test_U_CT_09_executeTransaction_works_correctly() public { + (address creditManager, address creditFacade, address creditConfigurator, address pool,) = _makeMocks(); + + string memory policyID = "setExpirationDate"; + + uint40 initialExpirationDate = uint40(block.timestamp); + + vm.mockCall( + creditFacade, + abi.encodeWithSelector(ICreditFacadeV3.expirationDate.selector), + abi.encode(initialExpirationDate) + ); + + vm.mockCall(pool, abi.encodeWithSelector(IPoolV3.creditManagerBorrowed.selector, creditManager), abi.encode(0)); + + uint40 expirationDate = uint40(block.timestamp + 2 days); + + uint256[] memory setValues = new uint256[](1); + setValues[0] = expirationDate; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 2 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = + keccak256(abi.encode(FRIEND, creditConfigurator, "setExpirationDate(uint40)", abi.encode(expirationDate))); + + vm.prank(FRIEND); + controllerTimelock.setExpirationDate(creditManager, expirationDate); + + vm.expectRevert(CallerNotExecutorException.selector); + vm.prank(USER); + controllerTimelock.executeTransaction(txHash); + + vm.expectRevert(TxExecutedOutsideTimeWindowException.selector); + vm.prank(FRIEND); + controllerTimelock.executeTransaction(txHash); + + vm.warp(block.timestamp + 20 days); + + vm.expectRevert(TxExecutedOutsideTimeWindowException.selector); + vm.prank(FRIEND); + controllerTimelock.executeTransaction(txHash); + + vm.warp(block.timestamp - 10 days); + + vm.mockCallRevert( + creditConfigurator, + abi.encodeWithSelector(ICreditConfiguratorV3.setExpirationDate.selector, expirationDate), + abi.encode("error") + ); + + vm.expectRevert(TxExecutionRevertedException.selector); + vm.prank(FRIEND); + controllerTimelock.executeTransaction(txHash); + + vm.clearMockedCalls(); + + vm.mockCall( + creditManager, abi.encodeWithSelector(ICreditManagerV3.creditFacade.selector), abi.encode(creditFacade) + ); + + vm.mockCall( + creditFacade, + abi.encodeWithSelector(ICreditFacadeV3.expirationDate.selector), + abi.encode(block.timestamp + 2 days) + ); + + vm.expectRevert(ParameterChangedAfterQueuedTxException.selector); + vm.prank(FRIEND); + controllerTimelock.executeTransaction(txHash); + + vm.mockCall( + creditFacade, + abi.encodeWithSelector(ICreditFacadeV3.expirationDate.selector), + abi.encode(initialExpirationDate) + ); + + vm.expectEmit(true, false, false, false); + emit ExecuteTransaction(txHash); + + vm.prank(FRIEND); + controllerTimelock.executeTransaction(txHash); + } + + /// @dev U:[CT-10]: forbidAdapter works correctly + function test_U_CT_10_forbidAdapter_works_correctly() public { + (address creditManager,, address creditConfigurator,,) = _makeMocks(); + + string memory policyID = "forbidAdapter"; + + uint256[] memory setValues = new uint256[](0); + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: false, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.forbidAdapter(creditManager, DUMB_ADDRESS); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.forbidAdapter(creditManager, DUMB_ADDRESS); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = + keccak256(abi.encode(admin, creditConfigurator, "forbidAdapter(address)", abi.encode(DUMB_ADDRESS))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + creditConfigurator, + "forbidAdapter(address)", + abi.encode(DUMB_ADDRESS), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.forbidAdapter(creditManager, DUMB_ADDRESS); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 0, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, ""); + + vm.expectCall( + creditConfigurator, abi.encodeWithSelector(ICreditConfiguratorV3.forbidAdapter.selector, DUMB_ADDRESS) + ); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-11]: setTokenLimit works correctly + function test_U_CT_11_setTokenLimit_works_correctly() public { + (,,, address pool, address poolQuotaKeeper) = _makeMocks(); + + address token = makeAddr("TOKEN"); + + vm.mockCall( + poolQuotaKeeper, + abi.encodeCall(IPoolQuotaKeeperV3.getTokenQuotaParams, (token)), + abi.encode(uint16(10), uint192(1e27), uint16(15), uint96(1e17), uint96(1e18), true) + ); + + string memory policyID = "setTokenLimit"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 1e19; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTokenLimit(pool, token, 1e19); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setTokenLimit(pool, token, 1e19); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTokenLimit(pool, token, 1e20); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode(admin, poolQuotaKeeper, "setTokenLimit(address,uint96)", abi.encode(token, uint96(1e19))) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + poolQuotaKeeper, + "setTokenLimit(address,uint96)", + abi.encode(token, uint96(1e19)), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setTokenLimit(pool, token, 1e19); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 1e18, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getTokenLimit, (poolQuotaKeeper, token))); + + vm.expectCall(poolQuotaKeeper, abi.encodeCall(PoolQuotaKeeperV3.setTokenLimit, (token, 1e19))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-12]: setQuotaIncreaseFee works correctly + function test_U_CT_12_setTokenQuotaIncreaseFee_works_correctly() public { + (,,, address pool, address poolQuotaKeeper) = _makeMocks(); + + address token = makeAddr("TOKEN"); + + vm.mockCall( + poolQuotaKeeper, + abi.encodeCall(IPoolQuotaKeeperV3.getTokenQuotaParams, (token)), + abi.encode(uint16(10), uint192(1e27), uint16(15), uint96(1e17), uint96(1e18), false) + ); + + string memory policyID = "setTokenQuotaIncreaseFee"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 20; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTokenQuotaIncreaseFee(pool, token, 20); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setTokenQuotaIncreaseFee(pool, token, 20); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTokenQuotaIncreaseFee(pool, token, 30); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode( + admin, poolQuotaKeeper, "setTokenQuotaIncreaseFee(address,uint16)", abi.encode(token, uint16(20)) + ) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + poolQuotaKeeper, + "setTokenQuotaIncreaseFee(address,uint16)", + abi.encode(token, uint16(20)), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setTokenQuotaIncreaseFee(pool, token, 20); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 15, "Sanity check value written incorrectly"); + + assertEq( + sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getTokenQuotaIncreaseFee, (poolQuotaKeeper, token)) + ); + + vm.expectCall(poolQuotaKeeper, abi.encodeCall(PoolQuotaKeeperV3.setTokenQuotaIncreaseFee, (token, 20))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-13]: setTotalDebt works correctly + function test_U_CT_13_setTotalDebtLimit_works_correctly() public { + (,,, address pool,) = _makeMocks(); + + vm.mockCall(pool, abi.encodeCall(IPoolV3.totalDebtLimit, ()), abi.encode(1e18)); + + string memory policyID = "setTotalDebtLimit"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 2e18; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTotalDebtLimit(pool, 2e18); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setTotalDebtLimit(pool, 2e18); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setTotalDebtLimit(pool, 3e18); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, pool, "setTotalDebtLimit(uint256)", abi.encode(2e18))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, admin, pool, "setTotalDebtLimit(uint256)", abi.encode(2e18), uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setTotalDebtLimit(pool, 2e18); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 1e18, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getTotalDebtLimit, (pool))); + + vm.expectCall(pool, abi.encodeCall(PoolV3.setTotalDebtLimit, (2e18))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-14]: setWithdrawFee works correctly + function test_U_CT_14_setWithdrawFee_works_correctly() public { + (,,, address pool,) = _makeMocks(); + + vm.mockCall(pool, abi.encodeCall(IPoolV3.withdrawFee, ()), abi.encode(10)); + + string memory policyID = "setWithdrawFee"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 20; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setWithdrawFee(pool, 20); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setWithdrawFee(pool, 20); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setWithdrawFee(pool, 30); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, pool, "setWithdrawFee(uint256)", abi.encode(20))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, admin, pool, "setWithdrawFee(uint256)", abi.encode(20), uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setWithdrawFee(pool, 20); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 10, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getWithdrawFee, (pool))); + + vm.expectCall(pool, abi.encodeCall(PoolV3.setWithdrawFee, (20))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-15A]: setMinQuotaRate works correctly + function test_U_CT_15A_setMinQuotaRate_works_correctly() public { + (,,, address pool, address poolQuotaKeeper) = _makeMocks(); + + address gauge = address(new GeneralMock()); + + vm.mockCall(poolQuotaKeeper, abi.encodeCall(IPoolQuotaKeeperV3.gauge, ()), abi.encode(gauge)); + + address token = makeAddr("TOKEN"); + + vm.mockCall( + gauge, + abi.encodeCall(IGaugeV3.quotaRateParams, (token)), + abi.encode(uint16(10), uint16(20), uint96(100), uint96(200)) + ); + + string memory policyID = "setMinQuotaRate"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMinQuotaRate(pool, token, 15); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setMinQuotaRate(pool, token, 15); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMinQuotaRate(pool, token, 25); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = + keccak256(abi.encode(admin, gauge, "changeQuotaMinRate(address,uint16)", abi.encode(token, uint16(15)))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + gauge, + "changeQuotaMinRate(address,uint16)", + abi.encode(token, uint16(15)), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setMinQuotaRate(pool, token, 15); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 10, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getMinQuotaRate, (gauge, token))); + + vm.expectCall(gauge, abi.encodeCall(GaugeV3.changeQuotaMinRate, (token, 15))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-15B]: setMaxQuotaRate works correctly + function test_U_CT_15B_setMaxQuotaRate_works_correctly() public { + (,,, address pool, address poolQuotaKeeper) = _makeMocks(); + + address gauge = address(new GeneralMock()); + + vm.mockCall(poolQuotaKeeper, abi.encodeCall(IPoolQuotaKeeperV3.gauge, ()), abi.encode(gauge)); + + address token = makeAddr("TOKEN"); + + vm.mockCall( + gauge, + abi.encodeCall(IGaugeV3.quotaRateParams, (token)), + abi.encode(uint16(10), uint16(20), uint96(100), uint96(200)) + ); + + string memory policyID = "setMaxQuotaRate"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 25; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxQuotaRate(pool, token, 25); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setMaxQuotaRate(pool, token, 25); + + // VERIFY THAT THE FUNCTION PERFORMS POLICY CHECKS + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setMaxQuotaRate(pool, token, 35); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = + keccak256(abi.encode(admin, gauge, "changeQuotaMaxRate(address,uint16)", abi.encode(token, uint16(25)))); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + gauge, + "changeQuotaMaxRate(address,uint16)", + abi.encode(token, uint16(25)), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setMaxQuotaRate(pool, token, 25); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 20, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getMaxQuotaRate, (gauge, token))); + + vm.expectCall(gauge, abi.encodeCall(GaugeV3.changeQuotaMaxRate, (token, 25))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-16]: forbidBoundsUpdate works correctly + function test_U_CT_16_forbidBoundsUpdate_works_correctly() public { + address priceFeed = makeAddr("PRICE_FEED"); + vm.mockCall(priceFeed, abi.encodeWithSignature("forbidBoundsUpdate()"), ""); + + string memory policyID = "forbidBoundsUpdate"; + + uint256[] memory setValues = new uint256[](0); + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: false, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.forbidBoundsUpdate(priceFeed); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.forbidBoundsUpdate(priceFeed); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256(abi.encode(admin, priceFeed, "forbidBoundsUpdate()", "")); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction(txHash, admin, priceFeed, "forbidBoundsUpdate()", "", uint40(block.timestamp + 1 days)); + + vm.prank(admin); + controllerTimelock.forbidBoundsUpdate(priceFeed); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq(sanityCheckValue, 0, "Sanity check value written incorrectly"); + + assertEq(sanityCheckCallData, ""); + + vm.expectCall(priceFeed, abi.encodeWithSignature("forbidBoundsUpdate()")); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-18]: setPriceFeed works correctly + function test_U_CT_18_setPriceFeed_works_correctly() public { + address token = makeAddr("TOKEN"); + address priceFeed = makeAddr("PRICE_FEED"); + address priceOracle = makeAddr("PRICE_ORACLE"); + vm.mockCall(priceOracle, abi.encodeCall(IPriceOracleV3.setPriceFeed, (token, priceFeed, 4500)), ""); + vm.mockCall( + priceOracle, + abi.encodeCall(IPriceOracleV3.priceFeedParams, (token)), + abi.encode(PriceFeedParams(priceFeed, 3000, false, 18)) + ); + + string memory policyID = string(abi.encodePacked("setPriceFeed_", Strings.toHexString(token))); + + uint256 pfKeccak = uint256(keccak256(abi.encode(priceFeed, uint32(4500)))); + uint256[] memory setValues = new uint256[](1); + setValues[0] = pfKeccak; + + Policy memory policy = Policy({ + enabled: false, + admin: admin, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + // VERIFY THAT THE FUNCTION CANNOT BE CALLED WITHOUT RESPECTIVE POLICY + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(admin); + controllerTimelock.setPriceFeed(priceOracle, token, priceFeed, 4500); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + // VERIFY THAT THE FUNCTION IS ONLY CALLABLE BY ADMIN + vm.expectRevert(ParameterChecksFailedException.selector); + vm.prank(USER); + controllerTimelock.setPriceFeed(priceOracle, token, priceFeed, 4500); + + // VERIFY THAT THE FUNCTION IS QUEUED AND EXECUTED CORRECTLY + bytes32 txHash = keccak256( + abi.encode(admin, priceOracle, "setPriceFeed(address,address,uint32)", abi.encode(token, priceFeed, 4500)) + ); + + vm.expectEmit(true, false, false, true); + emit QueueTransaction( + txHash, + admin, + priceOracle, + "setPriceFeed(address,address,uint32)", + abi.encode(token, priceFeed, 4500), + uint40(block.timestamp + 1 days) + ); + + vm.prank(admin); + controllerTimelock.setPriceFeed(priceOracle, token, priceFeed, 4500); + + (,,,,,, uint256 sanityCheckValue, bytes memory sanityCheckCallData) = + controllerTimelock.queuedTransactions(txHash); + + assertEq( + sanityCheckValue, uint256(keccak256(abi.encode(priceFeed, 3000))), "Sanity check value written incorrectly" + ); + + assertEq( + sanityCheckCallData, abi.encodeCall(ControllerTimelockV3.getCurrentPriceFeedHash, (priceOracle, token)) + ); + + vm.expectCall(priceOracle, abi.encodeCall(IPriceOracleV3.setPriceFeed, (token, priceFeed, 4500))); + + vm.warp(block.timestamp + 1 days); + + vm.prank(admin); + controllerTimelock.executeTransaction(txHash); + + (bool queued,,,,,,,) = controllerTimelock.queuedTransactions(txHash); + + assertTrue(!queued, "Transaction is still queued after execution"); + } + + /// @dev U:[CT-19]: executor logic is correct + function test_U_CT_19_executor_logic_is_correct() public { + (,,, address pool, address poolQuotaKeeper) = _makeMocks(); + + address token = makeAddr("TOKEN"); + + vm.startPrank(CONFIGURATOR); + + vm.expectEmit(true, false, false, true); + emit SetExecutor(FRIEND2, true); + + controllerTimelock.setExecutor(FRIEND2, true); + + vm.stopPrank(); + + vm.mockCall( + poolQuotaKeeper, + abi.encodeCall(IPoolQuotaKeeperV3.getTokenQuotaParams, (token)), + abi.encode(uint16(10), uint192(1e27), uint16(15), uint96(1e17), uint96(1e18), true) + ); + + string memory policyID = "setTokenLimit"; + + uint256[] memory setValues = new uint256[](1); + setValues[0] = 2e18; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + controllerTimelock.setPolicy(policyID, policy); + + vm.prank(FRIEND); + controllerTimelock.setTokenLimit(pool, token, 2e18); + + bytes32 txHash = keccak256( + abi.encode(FRIEND, poolQuotaKeeper, "setTokenLimit(address,uint96)", abi.encode(token, uint96(2e18))) + ); + + vm.warp(block.timestamp + 1 days); + + vm.prank(FRIEND2); + controllerTimelock.executeTransaction(txHash); + } +} diff --git a/contracts/test/PolicyManagerV3.unit.t.sol b/contracts/test/PolicyManagerV3.unit.t.sol new file mode 100644 index 0000000..f0f26f1 --- /dev/null +++ b/contracts/test/PolicyManagerV3.unit.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {PolicyManagerV3Harness, Policy} from "./PolicyManagerV3Harness.sol"; +import {PERCENTAGE_FACTOR} from "@gearbox-protocol/core-v2/contracts/libraries/Constants.sol"; + +// MOCKS +import {AddressProviderV3ACLMock} from + "@gearbox-protocol/core-v3/contracts/test/mocks/core/AddressProviderV3ACLMock.sol"; +import "@gearbox-protocol/core-v3/contracts/test/lib/constants.sol"; +import "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; + +contract PolicyManagerV3UnitTest is Test { + AddressProviderV3ACLMock public addressProvider; + + PolicyManagerV3Harness public policyManager; + + event SetPolicy(string indexed policyID, bool enabled); + + function setUp() public { + vm.prank(CONFIGURATOR); + addressProvider = new AddressProviderV3ACLMock(); + + policyManager = new PolicyManagerV3Harness(address(addressProvider)); + } + + /// + /// + /// TESTS + /// + /// + + /// @dev U:[PM-1]: setPolicy and getPolicy work correctly + function test_U_PM_01_setPolicy_getPolicy_work_correctly() public { + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: true, + checkSet: true, + intervalMinValue: 10, + intervalMaxValue: 20, + setValues: setValues + }); + + vm.expectRevert(CallerNotConfiguratorException.selector); + vm.prank(USER); + policyManager.setPolicy("TEST", policy); + + vm.expectEmit(true, false, false, true); + emit SetPolicy("TEST", true); + + vm.prank(CONFIGURATOR); + policyManager.setPolicy("TEST", policy); + + Policy memory policy2 = policyManager.getPolicy("TEST"); + + assertTrue(policy2.enabled, "Enabled not set by setPolicy"); + + assertEq(policy2.admin, FRIEND, "Admin was not set correctly"); + + assertEq(policy2.intervalMinValue, 10, "minValue is incorrect"); + + assertEq(policy2.intervalMaxValue, 20, "maxValue is incorrect"); + + assertEq(policy2.setValues.length, 1, "Set length incorrect"); + + assertEq(policy2.setValues[0], 15, "Set value incorrect"); + } + + /// @dev U:[PM-2]: checkPolicy fails on disabled policy + function test_U_PM_02_checkPolicy_false_on_disabled() public { + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: true, + checkSet: true, + intervalMinValue: 10, + intervalMaxValue: 20, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + policyManager.setPolicy("TEST", policy); + + vm.expectEmit(true, false, false, true); + emit SetPolicy("TEST", false); + + vm.prank(CONFIGURATOR); + policyManager.disablePolicy("TEST"); + + vm.prank(FRIEND); + assertTrue(!policyManager.checkPolicy("TEST", 15)); + } + + /// @dev U:[PM-3]: checkPolicy exactValue works correctly + function test_U_PM_03_checkPolicy_interval_works_correctly(uint256 minValue, uint256 maxValue, uint256 newValue) + public + { + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: true, + checkSet: false, + intervalMinValue: minValue, + intervalMaxValue: maxValue, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + policyManager.setPolicy("TEST", policy); + + vm.prank(FRIEND); + assertTrue((newValue <= maxValue && newValue >= minValue) || !policyManager.checkPolicy("TEST", newValue)); + } + + /// @dev U:[PM-4]: checkPolicy minValue works correctly + function test_U_PM_04_checkPolicy_set_works_correctly(uint256 setValue0, uint256 setValue1, uint256 newValue) + public + { + uint256[] memory setValues = new uint256[](2); + setValues[0] = setValue0; + setValues[1] = setValue1; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: false, + checkSet: true, + intervalMinValue: 0, + intervalMaxValue: 0, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + policyManager.setPolicy("TEST", policy); + + vm.prank(FRIEND); + assertTrue(newValue == setValue0 || newValue == setValue1 || !policyManager.checkPolicy("TEST", newValue)); + } + + /// @dev U:[PM-05]: checkPolicy returns false on caller not being admin + function test_U_PM_05_checkPolicy_returns_false_on_wrong_caller() public { + uint256[] memory setValues = new uint256[](1); + setValues[0] = 15; + + Policy memory policy = Policy({ + enabled: false, + admin: FRIEND, + delay: 1 days, + checkInterval: true, + checkSet: false, + intervalMinValue: 10, + intervalMaxValue: 20, + setValues: setValues + }); + + vm.prank(CONFIGURATOR); + policyManager.setPolicy("TEST", policy); + + vm.prank(USER); + assertTrue(!policyManager.checkPolicy("TEST", 15)); + + vm.prank(FRIEND); + assertTrue(policyManager.checkPolicy("TEST", 15)); + } +} diff --git a/contracts/test/PolicyManagerV3Harness.sol b/contracts/test/PolicyManagerV3Harness.sol new file mode 100644 index 0000000..0857487 --- /dev/null +++ b/contracts/test/PolicyManagerV3Harness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +// Gearbox Protocol. Generalized leverage for DeFi protocols +// (c) Gearbox Foundation, 2023. +pragma solidity ^0.8.17; + +import {PolicyManagerV3, Policy} from "../PolicyManagerV3.sol"; + +contract PolicyManagerV3Harness is PolicyManagerV3 { + constructor(address _addressProvider) PolicyManagerV3(_addressProvider) {} + + function checkPolicy(string memory policyID, uint256 newValue) external returns (bool) { + return _checkPolicy(policyID, newValue); + } +} diff --git a/package.json b/package.json index ad208d4..540441e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@1inch/solidity-utils": "2.4.0", "@chainlink/contracts": "0.4.0", "@gearbox-protocol/core-v2": "^1.19.0-base.17", - "@gearbox-protocol/core-v3": "^1.50.0-next.10", + "@gearbox-protocol/core-v3": "^1.50.0-next.11", "@gearbox-protocol/sdk-gov": "^2.1.0", "@openzeppelin/contracts": "4.9.3", "ds-test": "https://github.com/dapphub/ds-test", diff --git a/yarn.lock b/yarn.lock index 1ee62c2..03276ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -386,10 +386,10 @@ resolved "https://registry.yarnpkg.com/@gearbox-protocol/core-v2/-/core-v2-1.19.0-base.17.tgz#1cb409fcaf866de194f9f7253d2e6ce0de5bcf7d" integrity sha512-jPE32s/8Ihj0i7zPuXB4NbtoeWkG819+axGDH2a4RLE+BQ2+Dh+mhhgvTTDahFVrllVkDTUW6maHGWhDnGmNAg== -"@gearbox-protocol/core-v3@^1.50.0-next.10": - version "1.50.0-next.10" - resolved "https://registry.yarnpkg.com/@gearbox-protocol/core-v3/-/core-v3-1.50.0-next.10.tgz#84de6012cfb0bc793bcb22d7f954ef0b59cbc082" - integrity sha512-TMOzeJE7rfQ4zJvBu91HZokvQK5Jwuewy3LzapMPDwVszjELKI9x3kPW0xC133pWpQm/R7U3p3JdsBedX6WovQ== +"@gearbox-protocol/core-v3@^1.50.0-next.11": + version "1.50.0-next.11" + resolved "https://registry.yarnpkg.com/@gearbox-protocol/core-v3/-/core-v3-1.50.0-next.11.tgz#93f176f99acbaab7e769d6a60bd9de9e50ee9b1a" + integrity sha512-Ix9We0zCi0+CosKF8pRGOoFq6SUVkRvEnzNdEhvU2dBQTO6sX7+cIDd2kb7z4oy9X/7XE9l2X+bmKIyu+RiJpQ== "@gearbox-protocol/sdk-gov@^2.1.0": version "2.1.0"