diff --git a/.github/workflows/base-integration.yml b/.github/workflows/base-integration.yml index 23f0330af..cfeff30d5 100644 --- a/.github/workflows/base-integration.yml +++ b/.github/workflows/base-integration.yml @@ -1,4 +1,4 @@ -name: Base +name: Base on: [pull_request] @@ -121,7 +121,7 @@ jobs: retry_wait_seconds: 60 timeout_minutes: 20 max_attempts: 3 - command: time forge test --match-contract CypherIntegrationTest --fork-url base -vvv + command: time forge test --match-contract CypherIntegrationTest --fork-url base -vvv bounded-chainlink-composite-oracle: name: Bounded Chainlink Composite Oracle Test @@ -147,8 +147,8 @@ jobs: max_attempts: 3 command: time forge test --mc "ChainlinkBoundedCompositeOracleIntegrationTest" -vvv --ffi - chainlink-oracle-proxy: - name: Chainlink Oracle Proxy Test + chainlink-oev-wrapper: + name: Chainlink OEV Wrapper Test runs-on: ubuntu-latest steps: - name: Checkout code @@ -169,5 +169,28 @@ jobs: retry_wait_seconds: 60 timeout_minutes: 20 max_attempts: 3 - command: time forge test --mc "ChainlinkOracleProxyIntegrationTest" -vvv --ffi + command: time forge test --mc "ChainlinkOEVWrapperIntegration" -vvv --ffi + chainlink-oev-morpho-wrapper: + name: Chainlink OEV Morpho Wrapper Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Setup Environment + uses: ./.github/actions + + - name: Setup MIPs Permissions + uses: ./.github/actions/setup-mips-permissions + + - name: Run Integration Tests + uses: nick-fields/retry@v3 + with: + polling_interval_seconds: 30 + retry_wait_seconds: 60 + timeout_minutes: 20 + max_attempts: 3 + command: time forge test --mc "ChainlinkOEVMorphoWrapperIntegration" -vvv --ffi diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol new file mode 100644 index 000000000..46c1d2d84 --- /dev/null +++ b/proposals/ChainlinkOracleConfigs.sol @@ -0,0 +1,185 @@ +pragma solidity 0.8.19; + +import "@forge-std/Test.sol"; +import "@utils/ChainIds.sol"; + +abstract contract ChainlinkOracleConfigs is Test { + struct OracleConfig { + string oracleName; /// e.g., CHAINLINK_ETH_USD + string symbol; /// e.g., as found in addresses + string mTokenKey; /// e.g., MOONWELL_WETH (defaults to MOONWELL_[symbol] if not specified) + } + + struct MorphoOracleConfig { + string proxyName; /// e.g., CHAINLINK_stkWELL_USD (used for proxy identifier) + string priceFeedName; /// e.g., CHAINLINK_WELL_USD (the actual price feed oracle) + } + + /// oracle configurations per chain id + mapping(uint256 => OracleConfig[]) internal _oracleConfigs; + + /// morpho market configurations per chain id + mapping(uint256 => MorphoOracleConfig[]) internal _MorphoOracleConfigs; + + /// @dev oracles are listed in the order they are in the docs + /// https://docs.moonwell.fi/moonwell/protocol-information/contracts#base-contract-addresses + /// NOTE: some oracles are commented out as they have composite oracles, or won't be part of the mip to have oev wrapper + constructor() { + /// Initialize oracle configurations for Base + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("DAI_ORACLE", "DAI", "MOONWELL_DAI") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDC_USD", "USDC", "MOONWELL_USDC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDC_USD", "USDBC", "MOONWELL_USDBC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_ETH_USD", "WETH", "MOONWELL_WETH") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("cbETHETH_ORACLE", "cbETH", "MOONWELL_cbETH") + ); + // _oracleConfigs[BASE_CHAIN_ID].push( + // OracleConfig("CHAINLINK_WSTETH_STETH_COMPOSITE_ORACLE", "wstETH", "MOONWELL_wstETH") + // ); + // _oracleConfigs[BASE_CHAIN_ID].push( + // OracleConfig("CHAINLINK_RETH_ETH_COMPOSITE_ORACLE", "rETH", "MOONWELL_rETH") + // ); + // _oracleConfigs[BASE_CHAIN_ID].push( + // OracleConfig("CHAINLINK_WEETH_USD_COMPOSITE_ORACLE", "weETH", "MOONWELL_weETH") + // ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_AERO_ORACLE", "AERO", "MOONWELL_AERO") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_BTC_USD", "cbBTC", "MOONWELL_cbBTC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_EURC_USD", "EURC", "MOONWELL_EURC") + ); + // _oracleConfigs[BASE_CHAIN_ID].push( + // OracleConfig("CHAINLINK_wrsETH_COMPOSITE_ORACLE", "wrsETH", "MOONWELL_wrsETH") + // ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY", "MOONWELL_WELL") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDS_USD", "USDS", "MOONWELL_USDS") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_TBTC_USD", "TBTC", "MOONWELL_TBTC") + ); + // _oracleConfigs[BASE_CHAIN_ID].push( + // OracleConfig("CHAINLINK_LBTC_BTC_COMPOSITE_ORACLE", "LBTC", "MOONWELL_LBTC") + // ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_VIRTUAL_USD", "VIRTUAL", "MOONWELL_VIRTUAL") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_MORPHO_USD", "MORPHO", "MOONWELL_MORPHO") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_cbXRP_USD", "cbXRP", "MOONWELL_cbXRP") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_MAMO_USD", "MAMO", "MOONWELL_MAMO") + ); + + /// Initialize oracle configurations for Optimism + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDC_USD", "USDC", "MOONWELL_USDC") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDT_USD", "USDT", "MOONWELL_USDT") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_DAI_USD", "DAI", "MOONWELL_DAI") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_ETH_USD", "WETH", "MOONWELL_WETH") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_WBTC_USD", "WBTC", "MOONWELL_WBTC") + ); + // _oracleConfigs[OPTIMISM_CHAIN_ID].push( + // OracleConfig("CHAINLINK_WSTETH_USD_COMPOSITE_ORACLE", "wstETH", "MOONWELL_wstETH") + // ); + // _oracleConfigs[OPTIMISM_CHAIN_ID].push( + // OracleConfig("CHAINLINK_cbETH_USD_COMPOSITE_ORACLE", "cbETH", "MOONWELL_cbETH") + // ); + // _oracleConfigs[OPTIMISM_CHAIN_ID].push( + // OracleConfig("CHAINLINK_RETH_USD_COMPOSITE_ORACLE", "rETH", "MOONWELL_rETH") + // ); + // _oracleConfigs[OPTIMISM_CHAIN_ID].push( + // OracleConfig("CHAINLINK_WEETH_USD_COMPOSITE_ORACLE", "weETH", "MOONWELL_weETH") + // ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_OP_USD", "OP", "MOONWELL_OP") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_VELO_USD", "VELO", "MOONWELL_VELO") + ); + // _oracleConfigs[OPTIMISM_CHAIN_ID].push( + // OracleConfig("CHAINLINK_wrsETH_COMPOSITE_ORACLE", "wrsETH", "MOONWELL_wrsETH") + // ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDT_USD", "USDT0", "MOONWELL_USDT0") + ); + + /// Initialize Morpho market configurations for Base + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig("CHAINLINK_WELL_USD", "CHAINLINK_WELL_USD") + ); + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig("CHAINLINK_MAMO_USD", "CHAINLINK_MAMO_USD") + ); + + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig("CHAINLINK_stkWELL_USD", "CHAINLINK_WELL_USD") + ); + } + + function getOracleConfigurations( + uint256 chainId + ) public view returns (OracleConfig[] memory) { + OracleConfig[] memory configs = new OracleConfig[]( + _oracleConfigs[chainId].length + ); + + unchecked { + uint256 configLength = configs.length; + for (uint256 i = 0; i < configLength; i++) { + configs[i] = OracleConfig({ + oracleName: _oracleConfigs[chainId][i].oracleName, + symbol: _oracleConfigs[chainId][i].symbol, + mTokenKey: _oracleConfigs[chainId][i].mTokenKey + }); + } + } + + return configs; + } + + function getMorphoOracleConfigurations( + uint256 chainId + ) public view returns (MorphoOracleConfig[] memory) { + MorphoOracleConfig[] memory configs = new MorphoOracleConfig[]( + _MorphoOracleConfigs[chainId].length + ); + + unchecked { + uint256 configLength = configs.length; + for (uint256 i = 0; i < configLength; i++) { + configs[i] = MorphoOracleConfig({ + proxyName: _MorphoOracleConfigs[chainId][i].proxyName, + priceFeedName: _MorphoOracleConfigs[chainId][i] + .priceFeedName + }); + } + } + + return configs; + } +} diff --git a/proposals/mips/mip-o12/mip-o12.sol b/proposals/mips/mip-o12/mip-o12.sol index e1f0f3d2a..65485c5f5 100644 --- a/proposals/mips/mip-o12/mip-o12.sol +++ b/proposals/mips/mip-o12/mip-o12.sol @@ -9,11 +9,10 @@ import {ERC20} from "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {ProposalActions} from "@proposals/utils/ProposalActions.sol"; import {OPTIMISM_FORK_ID} from "@utils/ChainIds.sol"; import {ChainlinkFeedOEVWrapper} from "@protocol/oracles/ChainlinkFeedOEVWrapper.sol"; -import {DeployChainlinkOEVWrapper} from "@script/DeployChainlinkOEVWrapper.sol"; import {HybridProposal, ActionType} from "@proposals/proposalTypes/HybridProposal.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; -contract mipo12 is HybridProposal, DeployChainlinkOEVWrapper { +contract mipo12 is HybridProposal { using ProposalActions for *; string public constant override name = "MIP-O12"; @@ -31,7 +30,8 @@ contract mipo12 is HybridProposal, DeployChainlinkOEVWrapper { function deploy(Addresses addresses, address) public override { if (!addresses.isAddressSet("CHAINLINK_ETH_USD_OEV_WRAPPER")) { - deployChainlinkOEVWrapper(addresses, "CHAINLINK_ETH_USD"); + // new version of deploy script not compatiblw with old wrapper + //deployChainlinkOEVWrapper(addresses, "CHAINLINK_ETH_USD"); } } diff --git a/proposals/mips/mip-x14/mip-x14.sol b/proposals/mips/mip-x14/mip-x14.sol index 909102915..ff2bf61f4 100644 --- a/proposals/mips/mip-x14/mip-x14.sol +++ b/proposals/mips/mip-x14/mip-x14.sol @@ -10,12 +10,11 @@ import {ERC20} from "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; import {ProposalActions} from "@proposals/utils/ProposalActions.sol"; import {ChainlinkFeedOEVWrapper} from "@protocol/oracles/ChainlinkFeedOEVWrapper.sol"; import {ChainlinkCompositeOracle} from "@protocol/oracles/ChainlinkCompositeOracle.sol"; -import {DeployChainlinkOEVWrapper} from "@script/DeployChainlinkOEVWrapper.sol"; import {HybridProposal, ActionType} from "@proposals/proposalTypes/HybridProposal.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; import {OPTIMISM_FORK_ID, BASE_FORK_ID, OPTIMISM_CHAIN_ID, BASE_CHAIN_ID} from "@utils/ChainIds.sol"; -contract mipx14 is HybridProposal, DeployChainlinkOEVWrapper { +contract mipx14 is HybridProposal { using ProposalActions for *; using ChainIds for uint256; @@ -151,10 +150,11 @@ contract mipx14 is HybridProposal, DeployChainlinkOEVWrapper { ) ); if (!addresses.isAddressSet(wrapperName)) { - deployChainlinkOEVWrapper( - addresses, - _oracleConfigs[OPTIMISM_CHAIN_ID][i].oracleName - ); + // new version of deploy script not compatiblw with old wrapper + //deployChainlinkOEVWrapper( + // addresses, + // _oracleConfigs[OPTIMISM_CHAIN_ID][i].oracleName + //); } } vm.stopBroadcast(); @@ -170,10 +170,11 @@ contract mipx14 is HybridProposal, DeployChainlinkOEVWrapper { ) ); if (!addresses.isAddressSet(wrapperName)) { - deployChainlinkOEVWrapper( - addresses, - _oracleConfigs[BASE_CHAIN_ID][i].oracleName - ); + // new version of deploy script not compatiblw with old wrapper + //deployChainlinkOEVWrapper( + // addresses, + // _oracleConfigs[BASE_CHAIN_ID][i].oracleName + //); } } diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol new file mode 100644 index 000000000..570769c2a --- /dev/null +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -0,0 +1,570 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {console} from "forge-std/console.sol"; +import {HybridProposal} from "@proposals/proposalTypes/HybridProposal.sol"; +import {ChainlinkOracleConfigs} from "@proposals/ChainlinkOracleConfigs.sol"; +import {BASE_FORK_ID, OPTIMISM_FORK_ID, MOONBEAM_FORK_ID, BASE_CHAIN_ID, OPTIMISM_CHAIN_ID} from "@utils/ChainIds.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; +import {Comptroller} from "@protocol/Comptroller.sol"; +import {Networks} from "@proposals/utils/Networks.sol"; +import {ERC20} from "@openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ChainlinkOracle} from "@protocol/oracles/ChainlinkOracle.sol"; +import {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; +import {ChainlinkOEVMorphoWrapper} from "@protocol/oracles/ChainlinkOEVMorphoWrapper.sol"; +import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; +import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin-contracts/contracts/proxy/transparent/ProxyAdmin.sol"; +import {validateProxy} from "@proposals/utils/ProxyUtils.sol"; +import {OEVProtocolFeeRedeemer} from "@protocol/OEVProtocolFeeRedeemer.sol"; + +// this proposal should +// 1. deploy once instance of OEVProtocolFeeRedeemer (fee recipient) +// 2. deploy new non-upgradeable ChainlinkOEVWrapper contracts for core markets +// 3. upgrade existing ChainlinkOEVMorphoWrapper proxy contracts for Morpho markets => test that storage can still be accessed +// 4. call setFeed on the ChainlinkOracle for all core markets, to point to the new ChainlinkOEVWrapper contracts +contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { + string public constant override name = "MIP-X37"; + + string public constant MORPHO_IMPLEMENTATION_NAME = + "CHAINLINK_OEV_MORPHO_WRAPPER_IMPL"; + + uint16 public constant FEE_MULTIPLIER = 4000; // liquidator keeps 40% of the remaining collateral seized after repay amount + uint256 public constant MAX_ROUND_DELAY = 10; + uint256 public constant MAX_DECREMENTS = 10; + + /// @dev description setup + constructor() { + _setProposalDescription( + bytes(vm.readFile("./proposals/mips/mip-x37/x37.md")) + ); + } + + function primaryForkId() public pure override returns (uint256) { + return BASE_FORK_ID; + } + + // Deploy new instances of ChainlinkOEVWrapper (core markets) and ensure ChainlinkOEVMorphoWrapper implementation + // exists (Morpho). Also deploy once instance of OEVProtocolFeeRedeemer (fee recipient). + function deploy(Addresses addresses, address) public override { + _deployOEVProtocolFeeRedeemer(addresses); + _deployCoreWrappers(addresses); + _deployMorphoWrappers(addresses); + + vm.selectFork(OPTIMISM_FORK_ID); + _deployOEVProtocolFeeRedeemer(addresses); + _deployCoreWrappers(addresses); + + // no morpho markets on optimism + + // switch back + vm.selectFork(BASE_FORK_ID); + } + + // Upgrade Morpho wrappers and wire core feeds + function build(Addresses addresses) public override { + // Base: upgrade Morpho wrappers and wire core feeds + _upgradeMorphoWrappers(addresses, BASE_CHAIN_ID); + _wireCoreFeeds(addresses, BASE_CHAIN_ID); + + // Optimism: only wire core feeds + vm.selectFork(OPTIMISM_FORK_ID); + _wireCoreFeeds(addresses, OPTIMISM_CHAIN_ID); + + vm.selectFork(BASE_FORK_ID); + } + + function validate(Addresses addresses, address) public override { + // Validate Optimism + vm.selectFork(OPTIMISM_FORK_ID); + _validateFeedsPointToWrappers(addresses, OPTIMISM_CHAIN_ID); + _validateCoreWrappersConstructor(addresses, OPTIMISM_CHAIN_ID); + + // Validate Base + vm.selectFork(BASE_FORK_ID); + _validateFeedsPointToWrappers(addresses, BASE_CHAIN_ID); + _validateCoreWrappersConstructor(addresses, BASE_CHAIN_ID); + _validateMorphoWrappersImplementations(addresses, BASE_CHAIN_ID); + _validateMorphoWrappersState(addresses, BASE_CHAIN_ID); + } + + function _upgradeMorphoWrappers( + Addresses addresses, + uint256 chainId + ) internal { + MorphoOracleConfig[] + memory morphoConfigs = getMorphoOracleConfigurations(chainId); + + require( + addresses.isAddressSet(MORPHO_IMPLEMENTATION_NAME), + "Morpho implementation not deployed" + ); + address proxyAdmin = addresses.getAddress( + "CHAINLINK_ORACLE_PROXY_ADMIN" + ); + + for (uint256 i = 0; i < morphoConfigs.length; i++) { + string memory wrapperName = string( + abi.encodePacked(morphoConfigs[i].proxyName, "_ORACLE_PROXY") + ); + + require( + addresses.isAddressSet(wrapperName), + "Morpho wrapper not deployed" + ); + + _pushAction( + proxyAdmin, + abi.encodeWithSignature( + "upgradeAndCall(address,address,bytes)", + addresses.getAddress(wrapperName), + addresses.getAddress(MORPHO_IMPLEMENTATION_NAME), + abi.encodeWithSelector( + ChainlinkOEVMorphoWrapper.initializeV2.selector, + addresses.getAddress(morphoConfigs[i].priceFeedName), + addresses.getAddress("TEMPORAL_GOVERNOR"), + addresses.getAddress("MORPHO_BLUE"), + addresses.getAddress("CHAINLINK_ORACLE"), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), + FEE_MULTIPLIER, + MAX_ROUND_DELAY, + MAX_DECREMENTS + ) + ), + string.concat( + "Upgrade Morpho OEV wrapper via upgradeAndCall (with initializeV2) for ", + morphoConfigs[i].proxyName + ) + ); + } + } + + function _wireCoreFeeds(Addresses addresses, uint256 chainId) internal { + OracleConfig[] memory oracleConfigs = getOracleConfigurations(chainId); + + for (uint256 i = 0; i < oracleConfigs.length; i++) { + OracleConfig memory config = oracleConfigs[i]; + string memory wrapperName = string( + abi.encodePacked(config.oracleName, "_OEV_WRAPPER") + ); + address chainlinkOracle = addresses.getAddress("CHAINLINK_ORACLE"); + string memory symbol = ERC20(addresses.getAddress(config.symbol)) + .symbol(); + + address wrapperAddress = addresses.getAddress(wrapperName); + + _pushAction( + chainlinkOracle, + abi.encodeWithSignature( + "setFeed(string,address)", + symbol, + wrapperAddress + ), + string.concat("Set feed to OEV wrapper for ", symbol) + ); + } + } + + function _deployOEVProtocolFeeRedeemer(Addresses addresses) internal { + if (addresses.isAddressSet("OEV_PROTOCOL_FEE_REDEEMER")) { + return; + } + + vm.startBroadcast(); + OEVProtocolFeeRedeemer feeRedeemer = new OEVProtocolFeeRedeemer( + addresses.getAddress("MOONWELL_WETH") + ); + + // Whitelist all mTokens + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + for (uint256 i = 0; i < oracleConfigs.length; i++) { + feeRedeemer.whitelistMarket( + addresses.getAddress(oracleConfigs[i].mTokenKey) + ); + } + + feeRedeemer.transferOwnership( + addresses.getAddress("TEMPORAL_GOVERNOR") + ); + + addresses.addAddress("OEV_PROTOCOL_FEE_REDEEMER", address(feeRedeemer)); + vm.stopBroadcast(); + } + + /// @dev deploy direct instances (non-upgradeable) for all core markets + function _deployCoreWrappers(Addresses addresses) internal { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + + if (oracleConfigs.length == 0) { + console.log("No oracle configs found for chain %d", block.chainid); + return; + } + + vm.startBroadcast(); + + for (uint256 i = 0; i < oracleConfigs.length; i++) { + OracleConfig memory config = oracleConfigs[i]; + + string memory wrapperName = string( + abi.encodePacked(config.oracleName, "_OEV_WRAPPER") + ); + + ChainlinkOEVWrapper wrapper = new ChainlinkOEVWrapper( + addresses.getAddress(config.oracleName), + addresses.getAddress("TEMPORAL_GOVERNOR"), + addresses.getAddress("CHAINLINK_ORACLE"), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), + FEE_MULTIPLIER, + MAX_ROUND_DELAY, + MAX_DECREMENTS + ); + + // Set existing wrapper to deprecated and add new wrapper + if (addresses.isAddressSet(wrapperName)) { + address oldWrapper = addresses.getAddress(wrapperName); + + string memory deprecatedName = string( + abi.encodePacked(wrapperName, "_DEPRECATED") + ); + + if (addresses.isAddressSet(deprecatedName)) { + addresses.changeAddress(deprecatedName, oldWrapper, true); + } else { + addresses.addAddress(deprecatedName, oldWrapper); + } + + addresses.changeAddress(wrapperName, address(wrapper), true); + } else { + addresses.addAddress(wrapperName, address(wrapper)); + } + } + + vm.stopBroadcast(); + } + + function _deployMorphoWrappers(Addresses addresses) internal { + // Only ensure implementation exists; do not deploy new proxies. We'll upgrade existing proxies instead. + if (!addresses.isAddressSet("MORPHO_BLUE")) { + return; + } + + vm.startBroadcast(); + + // Ensure proxy admin exists for Morpho wrapper upgrades + if (!addresses.isAddressSet("CHAINLINK_ORACLE_PROXY_ADMIN")) { + ProxyAdmin proxyAdmin = new ProxyAdmin(); + addresses.addAddress( + "CHAINLINK_ORACLE_PROXY_ADMIN", + address(proxyAdmin) + ); + } + + // Deploy Morpho implementation if needed + if (!addresses.isAddressSet(MORPHO_IMPLEMENTATION_NAME)) { + ChainlinkOEVMorphoWrapper impl = new ChainlinkOEVMorphoWrapper(); + addresses.addAddress(MORPHO_IMPLEMENTATION_NAME, address(impl)); + } + + vm.stopBroadcast(); + } + + function _validateFeedsPointToWrappers( + Addresses addresses, + uint256 chainId + ) internal view { + OracleConfig[] memory oracleConfigs = getOracleConfigurations(chainId); + address chainlinkOracle = addresses.getAddress("CHAINLINK_ORACLE"); + for (uint256 i = 0; i < oracleConfigs.length; i++) { + OracleConfig memory config = oracleConfigs[i]; + string memory wrapperName = string( + abi.encodePacked(config.oracleName, "_OEV_WRAPPER") + ); + string memory symbol = ERC20(addresses.getAddress(config.symbol)) + .symbol(); + address configured = address( + ChainlinkOracle(chainlinkOracle).getFeed(symbol) + ); + address expected = addresses.getAddress(wrapperName); + assertEq( + configured, + expected, + string.concat("Feed not set to wrapper for ", symbol) + ); + } + } + + function _validateCoreWrappersConstructor( + Addresses addresses, + uint256 chainId + ) internal view { + OracleConfig[] memory oracleConfigs = getOracleConfigurations(chainId); + address expectedOwner = addresses.getAddress("TEMPORAL_GOVERNOR"); + address expectedChainlinkOracle = addresses.getAddress( + "CHAINLINK_ORACLE" + ); + + for (uint256 i = 0; i < oracleConfigs.length; i++) { + OracleConfig memory config = oracleConfigs[i]; + string memory wrapperName = string( + abi.encodePacked(config.oracleName, "_OEV_WRAPPER") + ); + + ChainlinkOEVWrapper wrapper = ChainlinkOEVWrapper( + payable(addresses.getAddress(wrapperName)) + ); + + // Validate priceFeed + assertEq( + address(wrapper.priceFeed()), + addresses.getAddress(config.oracleName), + string.concat( + "Core wrapper priceFeed mismatch for ", + wrapperName + ) + ); + + // Validate feeMultiplier + assertEq( + wrapper.feeMultiplier(), + FEE_MULTIPLIER, + string.concat( + "Core wrapper feeMultiplier mismatch for ", + wrapperName + ) + ); + + // Validate feeRecipient + assertEq( + wrapper.feeRecipient(), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), + string.concat( + "Core wrapper feeRecipient mismatch for ", + wrapperName + ) + ); + + // Validate cachedRoundId (should be > 0 as it's set to priceFeed.latestRound()) + assertGt( + wrapper.cachedRoundId(), + 0, + string.concat( + "Core wrapper cachedRoundId should be > 0 for ", + wrapperName + ) + ); + + // Validate maxRoundDelay + assertEq( + wrapper.maxRoundDelay(), + MAX_ROUND_DELAY, + string.concat( + "Core wrapper maxRoundDelay mismatch for ", + wrapperName + ) + ); + + // Validate maxDecrements + assertEq( + wrapper.maxDecrements(), + MAX_DECREMENTS, + string.concat( + "Core wrapper maxDecrements mismatch for ", + wrapperName + ) + ); + + // Validate chainlinkOracle + assertEq( + address(wrapper.chainlinkOracle()), + expectedChainlinkOracle, + string.concat( + "Core wrapper chainlinkOracle mismatch for ", + wrapperName + ) + ); + + // Validate owner + assertEq( + wrapper.owner(), + expectedOwner, + string.concat("Core wrapper owner mismatch for ", wrapperName) + ); + } + } + + function _validateMorphoWrappersImplementations( + Addresses addresses, + uint256 chainId + ) internal view { + MorphoOracleConfig[] + memory morphoConfigs = getMorphoOracleConfigurations(chainId); + if (morphoConfigs.length == 0) return; + + for (uint256 i = 0; i < morphoConfigs.length; i++) { + string memory wrapperName = string( + abi.encodePacked(morphoConfigs[i].proxyName, "_ORACLE_PROXY") + ); + + validateProxy( + vm, + addresses.getAddress(wrapperName), + addresses.getAddress(MORPHO_IMPLEMENTATION_NAME), + addresses.getAddress("CHAINLINK_ORACLE_PROXY_ADMIN"), + string.concat("morpho wrapper validation: ", wrapperName) + ); + } + } + + function _validateMorphoWrappersState( + Addresses addresses, + uint256 chainId + ) internal view { + MorphoOracleConfig[] + memory morphoConfigs = getMorphoOracleConfigurations(chainId); + if (morphoConfigs.length == 0) return; + + address morphoBlue = addresses.getAddress("MORPHO_BLUE"); + address expectedOwner = addresses.getAddress("TEMPORAL_GOVERNOR"); + address expectedChainlinkOracle = addresses.getAddress( + "CHAINLINK_ORACLE" + ); + + for (uint256 i = 0; i < morphoConfigs.length; i++) { + string memory wrapperName = string( + abi.encodePacked(morphoConfigs[i].proxyName, "_ORACLE_PROXY") + ); + ChainlinkOEVMorphoWrapper wrapper = ChainlinkOEVMorphoWrapper( + addresses.getAddress(wrapperName) + ); + + // Validate priceFeed + assertEq( + address(wrapper.priceFeed()), + addresses.getAddress(morphoConfigs[i].priceFeedName), + string.concat( + "Morpho wrapper priceFeed mismatch for ", + wrapperName + ) + ); + + // Validate morphoBlue + assertEq( + address(wrapper.morphoBlue()), + morphoBlue, + string.concat( + "Morpho wrapper morphoBlue mismatch for ", + wrapperName + ) + ); + + // Validate chainlinkOracle + assertEq( + address(wrapper.chainlinkOracle()), + expectedChainlinkOracle, + string.concat( + "Morpho wrapper chainlinkOracle mismatch for ", + wrapperName + ) + ); + + // Validate feeRecipient + assertEq( + wrapper.feeRecipient(), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), + string.concat( + "Morpho wrapper feeRecipient mismatch for ", + wrapperName + ) + ); + + // Validate feeMultiplier + assertEq( + wrapper.feeMultiplier(), + FEE_MULTIPLIER, + string.concat( + "Morpho wrapper feeMultiplier mismatch for ", + wrapperName + ) + ); + + // Validate cachedRoundId (should be > 0 as it's set to priceFeed.latestRound()) + assertGt( + wrapper.cachedRoundId(), + 0, + string.concat( + "Morpho wrapper cachedRoundId should be > 0 for ", + wrapperName + ) + ); + + // Validate maxRoundDelay + assertEq( + wrapper.maxRoundDelay(), + MAX_ROUND_DELAY, + string.concat( + "Morpho wrapper maxRoundDelay mismatch for ", + wrapperName + ) + ); + + // Validate maxDecrements + assertEq( + wrapper.maxDecrements(), + MAX_DECREMENTS, + string.concat( + "Morpho wrapper maxDecrements mismatch for ", + wrapperName + ) + ); + + // Validate owner + assertEq( + wrapper.owner(), + expectedOwner, + string.concat("Morpho wrapper owner mismatch for ", wrapperName) + ); + + // Validate decimals behavior + uint8 d = wrapper.decimals(); + assertEq( + d, + AggregatorV3Interface( + addresses.getAddress(morphoConfigs[i].priceFeedName) + ).decimals(), + string.concat( + "Morpho wrapper decimals mismatch for ", + wrapperName + ) + ); + + // Validate latestRoundData behavior + (uint80 roundId, int256 answer, , uint256 updatedAt, ) = wrapper + .latestRoundData(); + assertGt( + uint256(roundId), + 0, + string.concat( + "Morpho wrapper roundId invalid for ", + wrapperName + ) + ); + assertGt( + uint256(updatedAt), + 0, + string.concat( + "Morpho wrapper updatedAt invalid for ", + wrapperName + ) + ); + assertGt( + uint256(answer), + 0, + string.concat("Morpho wrapper answer invalid for ", wrapperName) + ); + } + } +} diff --git a/proposals/mips/mip-x37/x37.md b/proposals/mips/mip-x37/x37.md new file mode 100644 index 000000000..66f633a73 --- /dev/null +++ b/proposals/mips/mip-x37/x37.md @@ -0,0 +1 @@ +## MIP-X37: Upgrade ChailinkOracleProxy for OEV Wrapper diff --git a/proposals/mips/mips.json b/proposals/mips/mips.json index a14d3e762..3b46bfe66 100755 --- a/proposals/mips/mips.json +++ b/proposals/mips/mips.json @@ -1,4 +1,11 @@ [ + { + "envpath": "", + "governor": "MultichainGovernor", + "id": 0, + "path": "mip-x37.sol/mipx37.json", + "proposalType": "HybridProposal" + }, { "envpath": "", "governor": "MultichainGovernor", diff --git a/script/DeployChainlinkOEVWrapper.s.sol b/script/DeployChainlinkOEVWrapper.s.sol new file mode 100644 index 000000000..9958f731e --- /dev/null +++ b/script/DeployChainlinkOEVWrapper.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {Script} from "@forge-std/Script.sol"; + +import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; + +import {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; + +/// @dev deprecated, no longer compatible with new wrapper +contract DeployChainlinkOEVWrapper is Script { + function deploy( + Addresses // addresses + ) public pure returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { + // no longer compatible with new wrapper + return ( + TransparentUpgradeableProxy(payable(address(0))), + ChainlinkOEVWrapper(payable(address(0))) + ); + } + + function validate( + Addresses addresses, + TransparentUpgradeableProxy proxy, + ChainlinkOEVWrapper implementation + ) public view { + // no longer compatible with new wrapper + } + + function run() + public + returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) + { + Addresses addresses = new Addresses(); + ( + TransparentUpgradeableProxy proxy, + ChainlinkOEVWrapper implementation + ) = deploy(addresses); + validate(addresses, proxy, implementation); + return (proxy, implementation); + } +} diff --git a/script/DeployChainlinkOEVWrapper.sol b/script/DeployChainlinkOEVWrapper.sol deleted file mode 100644 index 0c9d9aef1..000000000 --- a/script/DeployChainlinkOEVWrapper.sol +++ /dev/null @@ -1,26 +0,0 @@ -pragma solidity 0.8.19; - -import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; -import {ChainlinkFeedOEVWrapper} from "@protocol/oracles/ChainlinkFeedOEVWrapper.sol"; - -contract DeployChainlinkOEVWrapper { - function deployChainlinkOEVWrapper( - Addresses addresses, - string memory feed - ) public returns (ChainlinkFeedOEVWrapper wrapper) { - wrapper = new ChainlinkFeedOEVWrapper( - addresses.getAddress(feed), - 99, - addresses.getAddress("TEMPORAL_GOVERNOR"), - addresses.getAddress("MOONWELL_WETH"), - addresses.getAddress("WETH"), - uint8(10), - uint8(10) - ); - - addresses.addAddress( - string(abi.encodePacked(feed, "_OEV_WRAPPER")), - address(wrapper) - ); - } -} diff --git a/script/DeployChainlinkOracleProxy.s.sol b/script/DeployChainlinkOracleProxy.s.sol deleted file mode 100644 index 5b90d3c99..000000000 --- a/script/DeployChainlinkOracleProxy.s.sol +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.19; - -import {Script} from "@forge-std/Script.sol"; - -import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; - -import {ChainlinkOracleProxy} from "@protocol/oracles/ChainlinkOracleProxy.sol"; -import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; - -contract DeployChainlinkOracleProxy is Script { - function deploy( - Addresses addresses - ) public returns (TransparentUpgradeableProxy, ChainlinkOracleProxy) { - vm.startBroadcast(); - - // Deploy the implementation contract - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); - - // Get the ProxyAdmin address - address proxyAdmin = addresses.getAddress("MRD_PROXY_ADMIN"); - - // Prepare initialization data - bytes memory initData = abi.encodeWithSelector( - ChainlinkOracleProxy.initialize.selector, - addresses.getAddress("CHAINLINK_WELL_USD"), // Price feed address - addresses.getAddress("MRD_PROXY_ADMIN") // Owner address - ); - - // Deploy the TransparentUpgradeableProxy - TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - initData - ); - - vm.stopBroadcast(); - - // Record deployed contracts - addresses.addAddress( - "CHAINLINK_ORACLE_PROXY_IMPL", - address(implementation) - ); - addresses.addAddress("CHAINLINK_ORACLE_PROXY", address(proxy)); - - return (proxy, implementation); - } - - function validate( - Addresses addresses, - TransparentUpgradeableProxy proxy, - ChainlinkOracleProxy implementation - ) public view { - // Get proxy admin contract - ProxyAdmin proxyAdmin = ProxyAdmin( - addresses.getAddress("MRD_PROXY_ADMIN") - ); - - // Validate proxy configuration - address actualImplementation = proxyAdmin.getProxyImplementation( - ITransparentUpgradeableProxy(address(proxy)) - ); - address actualProxyAdmin = proxyAdmin.getProxyAdmin( - ITransparentUpgradeableProxy(address(proxy)) - ); - - require( - actualImplementation == address(implementation), - "DeployChainlinkOracleProxy: proxy implementation mismatch" - ); - - require( - actualProxyAdmin == address(proxyAdmin), - "DeployChainlinkOracleProxy: proxy admin mismatch" - ); - - // Validate implementation configuration - ChainlinkOracleProxy proxyInstance = ChainlinkOracleProxy( - address(proxy) - ); - - require( - proxyInstance.owner() == addresses.getAddress("MRD_PROXY_ADMIN"), - "DeployChainlinkOracleProxy: implementation owner mismatch" - ); - - require( - address(proxyInstance.priceFeed()) == - addresses.getAddress("CHAINLINK_WELL_USD"), - "DeployChainlinkOracleProxy: price feed address mismatch" - ); - } - - function run() - public - returns (TransparentUpgradeableProxy, ChainlinkOracleProxy) - { - Addresses addresses = new Addresses(); - ( - TransparentUpgradeableProxy proxy, - ChainlinkOracleProxy implementation - ) = deploy(addresses); - validate(addresses, proxy, implementation); - return (proxy, implementation); - } -} diff --git a/script/templates/CreateMorphoMarket.s.sol b/script/templates/CreateMorphoMarket.s.sol index 10fadda24..815a78eb8 100644 --- a/script/templates/CreateMorphoMarket.s.sol +++ b/script/templates/CreateMorphoMarket.s.sol @@ -12,7 +12,7 @@ import {IMorphoBlue} from "@protocol/morpho/IMorphoBlue.sol"; import {IMorphoChainlinkOracleV2Factory} from "@protocol/morpho/IMorphoChainlinkOracleFactory.sol"; import {IMorphoChainlinkOracleV2} from "@protocol/morpho/IMorphoChainlinkOracleV2.sol"; import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; -import {ChainlinkOracleProxy} from "@protocol/oracles/ChainlinkOracleProxy.sol"; +import {ChainlinkOEVMorphoWrapper} from "@protocol/oracles/ChainlinkOEVMorphoWrapper.sol"; import {TransparentUpgradeableProxy} from "@openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {ProxyAdmin} from "@openzeppelin-contracts/contracts/proxy/transparent/ProxyAdmin.sol"; import {IERC20} from "@openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; @@ -42,10 +42,18 @@ contract CreateMorphoMarket is Script, Test { uint8 baseFeedDecimals; // e.g. 18 string quoteFeedName; // e.g. CHAINLINK_USDC_USD uint8 quoteFeedDecimals; // e.g. 6 + string coreMarketAsFeeRecipient; // e.g. MOONWELL_WELL } uint256 internal constant MARKET_PARAMS_BYTES_LENGTH = 5 * 32; + uint16 internal constant FEE_MULTIPLIER = 9000; // 90% + uint8 internal constant MAX_ROUND_DELAY = 10; + uint8 internal constant MAX_DECREMENTS = 10; + + string internal constant MORPHO_IMPLEMENTATION_NAME = + "CHAINLINK_OEV_MORPHO_WRAPPER_IMPL"; + function run() external { // Setup fork for Base chain BASE_FORK_ID.createForksAndSelect(); @@ -112,6 +120,18 @@ contract CreateMorphoMarket is Script, Test { ); ocfg.addressName = json.readString(".oracle.addressName"); ocfg.proxyAddressName = json.readString(".oracle.proxyAddressName"); + + // coreMarketAsFeeRecipient is required + bytes memory feeRecipientRaw = json.parseRaw( + ".oracle.coreMarketAsFeeRecipient" + ); + require( + feeRecipientRaw.length > 0, + "oracle.coreMarketAsFeeRecipient is required" + ); + ocfg.coreMarketAsFeeRecipient = json.readString( + ".oracle.coreMarketAsFeeRecipient" + ); } function _computeMarketId( @@ -235,23 +255,22 @@ contract CreateMorphoMarket is Script, Test { Addresses addresses, CreateMorphoMarket.OracleConfig memory ocfg ) internal returns (AggregatorV3Interface) { - if (addresses.isAddressSet(ocfg.addressName)) { + // Reuse the proxy if it already exists for this market's base feed wrapper + string memory proxyAddressName = string( + abi.encodePacked(ocfg.proxyAddressName, "_PROXY") + ); + if (addresses.isAddressSet(proxyAddressName)) { return - AggregatorV3Interface(addresses.getAddress(ocfg.addressName)); + AggregatorV3Interface(addresses.getAddress(proxyAddressName)); } - string memory logicAddressName = string( - abi.encodePacked(ocfg.proxyAddressName, "_IMPL") - ); - - ChainlinkOracleProxy logic; - - if (!addresses.isAddressSet(logicAddressName)) { - logic = new ChainlinkOracleProxy(); - addresses.addAddress(logicAddressName, address(logic)); + ChainlinkOEVMorphoWrapper logic; + if (!addresses.isAddressSet(MORPHO_IMPLEMENTATION_NAME)) { + logic = new ChainlinkOEVMorphoWrapper(); + addresses.addAddress(MORPHO_IMPLEMENTATION_NAME, address(logic)); } else { - logic = ChainlinkOracleProxy( - addresses.getAddress(logicAddressName) + logic = ChainlinkOEVMorphoWrapper( + addresses.getAddress(MORPHO_IMPLEMENTATION_NAME) ); } @@ -262,32 +281,32 @@ contract CreateMorphoMarket is Script, Test { "CHAINLINK_ORACLE_PROXY_ADMIN", address(proxyAdmin) ); + proxyAdmin.transferOwnership( + addresses.getAddress("TEMPORAL_GOVERNOR") + ); } else { proxyAdmin = ProxyAdmin( addresses.getAddress("CHAINLINK_ORACLE_PROXY_ADMIN") ); } - string memory proxyAddressName = string( - abi.encodePacked(ocfg.proxyAddressName, "_PROXY") + bytes memory initData = abi.encodeWithSelector( + ChainlinkOEVMorphoWrapper.initializeV2.selector, + addresses.getAddress(ocfg.baseFeedName), + addresses.getAddress("TEMPORAL_GOVERNOR"), + addresses.getAddress("MORPHO_BLUE"), + ocfg.coreMarketAsFeeRecipient, + FEE_MULTIPLIER, + MAX_ROUND_DELAY, + MAX_DECREMENTS ); - TransparentUpgradeableProxy proxy; - if (!addresses.isAddressSet(proxyAddressName)) { - proxy = new TransparentUpgradeableProxy( - address(logic), - address(proxyAdmin), - "" - ); - - ChainlinkOracleProxy(address(proxy)).initialize( - addresses.getAddress(ocfg.baseFeedName), - addresses.getAddress("TEMPORAL_GOVERNOR") - ); - addresses.addAddress(proxyAddressName, address(proxy)); - } - - return AggregatorV3Interface(address(proxy)); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(logic), + address(proxyAdmin), + initData + ); + addresses.addAddress(proxyAddressName, address(proxy)); } function _createOracle( diff --git a/src/OEVProtocolFeeRedeemer.sol b/src/OEVProtocolFeeRedeemer.sol new file mode 100644 index 000000000..9defdaad3 --- /dev/null +++ b/src/OEVProtocolFeeRedeemer.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {MErc20Interface} from "./MTokenInterfaces.sol"; +import {EIP20Interface} from "./EIP20Interface.sol"; +import {Ownable} from "@openzeppelin-contracts/contracts/access/Ownable.sol"; + +/** + * @title OEVProtocolFeeRedeemer + * @notice This contract collects OEV fees from liquidations and allows anyone to trigger redemptions or adding to the + * mToken reserves. Contract calls are permissionless. + * @dev This contract receives fees from ChainlinkOEVWrapper and ChainlinkOEVMorphoWrapper. Handles mToken, + * underlying token, and native ETH balances. + */ +contract OEVProtocolFeeRedeemer is Ownable { + event ReservesAddedFromOEV(address indexed mToken, uint256 amount); + + modifier onlyWhitelistedMarkets(address _market) { + require( + whitelistedMarkets[_market], + "OEVProtocolFeeRedeemer: not whitelisted market" + ); + _; + } + + mapping(address => bool) public whitelistedMarkets; + + address public immutable MOONWELL_WETH; + + /** + * @notice Contract constructor + * @param _moonwellWETH Address for WETH mToken + */ + constructor(address _moonwellWETH) { + MOONWELL_WETH = _moonwellWETH; + whitelistedMarkets[_moonwellWETH] = true; + + _transferOwnership(msg.sender); + } + + /** + * @notice Allows the contract + */ + function whitelistMarket(address _market) external onlyOwner { + whitelistedMarkets[_market] = true; + } + + /** + * @notice Allows anyone to redeem this contract's mTokens and add the reserves to the mToken + * @param _mToken Address of the mToken to redeem and add reserves to + */ + function redeemAndAddReserves( + address _mToken + ) external onlyWhitelistedMarkets(_mToken) { + ( + MErc20Interface mToken, + EIP20Interface underlyingToken + ) = _getMTokenAndUnderlying(_mToken); + + uint256 nativeBalanceBefore = address(this).balance; + uint256 underlyingBalanceBefore = underlyingToken.balanceOf( + address(this) + ); + uint256 mTokenBalance = EIP20Interface(_mToken).balanceOf( + address(this) + ); + + // Note: mWETH will unwrap to native ETH via WETH_UNWRAPPER + require( + mToken.redeem(mTokenBalance) == 0, + "OEVProtocolFeeRedeemer: redemption failed" + ); + + // If we received native ETH (from mWETH), wrap it back to WETH + uint256 nativeDelta = address(this).balance - nativeBalanceBefore; + _wrapNativeToWETH(underlyingToken, nativeDelta); + + uint256 amount = underlyingToken.balanceOf(address(this)) - + underlyingBalanceBefore; + _addReservesToMToken(mToken, underlyingToken, amount); + } + + /** + * @notice Add reserves from underlying token balance + * @param _mToken Address of the mToken to add reserves to + */ + function addReserves( + address _mToken + ) external onlyWhitelistedMarkets(_mToken) { + ( + MErc20Interface mToken, + EIP20Interface underlyingToken + ) = _getMTokenAndUnderlying(_mToken); + + uint256 amount = underlyingToken.balanceOf(address(this)); + require(amount > 0, "OEVProtocolFeeRedeemer: no underlying balance"); + + _addReservesToMToken(mToken, underlyingToken, amount); + } + + /** + * @notice Add reserves from native ETH balance + */ + function addReservesNative() external { + ( + MErc20Interface mToken, + EIP20Interface underlyingToken + ) = _getMTokenAndUnderlying(MOONWELL_WETH); + + uint256 amount = address(this).balance; + require(amount > 0, "OEVProtocolFeeRedeemer: no native balance"); + + _wrapNativeToWETH(underlyingToken, amount); + _addReservesToMToken(mToken, underlyingToken, amount); + } + + /** + * @dev Get and validate mToken and underlying token + * @param _mToken Address of the mToken + * @return mToken The validated mToken interface + * @return underlyingToken The underlying token interface + */ + function _getMTokenAndUnderlying( + address _mToken + ) + internal + view + returns (MErc20Interface mToken, EIP20Interface underlyingToken) + { + mToken = MErc20Interface(_mToken); + underlyingToken = EIP20Interface(mToken.underlying()); + } + + /** + * @dev Wrap native ETH to WETH (if needed) + * @param underlyingToken The underlying token (should be WETH) + * @param amount The amount of native ETH to wrap + */ + function _wrapNativeToWETH( + EIP20Interface underlyingToken, + uint256 amount + ) internal { + if (amount > 0) { + (bool success, ) = address(underlyingToken).call{value: amount}( + abi.encodeWithSignature("deposit()") + ); + require(success, "OEVProtocolFeeRedeemer: WETH deposit failed"); + } + } + + /** + * @dev Add reserves to mToken + emit event + * @param mToken The mToken to add reserves to + * @param underlyingToken The underlying token to approve + * @param amount The amount to add as reserves + */ + function _addReservesToMToken( + MErc20Interface mToken, + EIP20Interface underlyingToken, + uint256 amount + ) internal { + underlyingToken.approve(address(mToken), amount); + mToken._addReserves(amount); + emit ReservesAddedFromOEV(address(mToken), amount); + } + + receive() external payable {} +} diff --git a/src/interfaces/IChainlinkOracle.sol b/src/interfaces/IChainlinkOracle.sol new file mode 100644 index 000000000..38ab83db8 --- /dev/null +++ b/src/interfaces/IChainlinkOracle.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: agpl-3.0 +pragma solidity 0.8.19; + +import "../oracles/AggregatorV3Interface.sol"; +import "../MToken.sol"; + +interface IChainlinkOracle { + function getFeed( + string memory symbol + ) external view returns (AggregatorV3Interface); + + function getUnderlyingPrice(MToken mToken) external view returns (uint256); +} diff --git a/src/morpho/IMorphoBlue.sol b/src/morpho/IMorphoBlue.sol index 688fcc212..b4ec1dd7f 100644 --- a/src/morpho/IMorphoBlue.sol +++ b/src/morpho/IMorphoBlue.sol @@ -43,4 +43,22 @@ interface IMorphoBlue { address onBehalf, address receiver ) external returns (uint256 assetsBorrowed, uint256 sharesBorrowed); + + /// @notice Liquidates the given `borrower` position. + /// @dev Either `seizedAssets` or `repaidShares` should be zero. + /// @dev Liquidating a position with bad debt (ie. with collateral value < borrowed value) will socialize the bad debt. + /// @param marketParams The market to liquidate in. + /// @param borrower The address of the borrower to liquidate. + /// @param seizedAssets The amount of collateral to seize. + /// @param repaidShares The amount of shares to repay. + /// @param data Arbitrary data to pass to the liquidation callback. Pass empty if no callback needed. + /// @return seizedAssets The amount of collateral seized. + /// @return repaidAssets The amount of assets repaid. + function liquidate( + MarketParams memory marketParams, + address borrower, + uint256 seizedAssets, + uint256 repaidShares, + bytes memory data + ) external returns (uint256, uint256); } diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol new file mode 100644 index 000000000..075e7d0a5 --- /dev/null +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "./AggregatorV3Interface.sol"; +import {EIP20Interface} from "../EIP20Interface.sol"; +import {IMorphoBlue} from "../morpho/IMorphoBlue.sol"; +import {MarketParams} from "../morpho/IMetaMorpho.sol"; +import {IMorphoChainlinkOracleV2} from "../morpho/IMorphoChainlinkOracleV2.sol"; +import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; + +/** + * @title ChainlinkOEVMorphoWrapper + * @notice A wrapper for Chainlink price feeds that allows early updates for liquidation + * @dev This contract implements the AggregatorV3Interface and adds OEV (Oracle Extractable Value) functionality + */ +contract ChainlinkOEVMorphoWrapper is + Initializable, + OwnableUpgradeable, + AggregatorV3Interface +{ + /// @notice The maximum basis points for the fee multiplier + uint16 public constant MAX_BPS = 10000; + + /// @notice Price mantissa decimals (used by ChainlinkOracle) + uint8 private constant PRICE_MANTISSA_DECIMALS = 18; + + /// @notice The ChainlinkOracle contract + IChainlinkOracle public chainlinkOracle; + + /// @notice The Chainlink price feed this proxy forwards to + AggregatorV3Interface public priceFeed; + + /// @notice The Morpho Blue contract address + IMorphoBlue public morphoBlue; + + /// @notice The address that will receive the OEV fees + address public feeRecipient; + + /// @notice The fee multiplier for the OEV fees + /// @dev Represented as a percentage + uint16 public feeMultiplier; + + /// @notice The last cached round id + uint256 public cachedRoundId; + + /// @notice The max round delay (seconds) + uint256 public maxRoundDelay; + + /// @notice The max decrements + uint256 public maxDecrements; + + /// @notice Emitted when the fee recipient is changed + event FeeRecipientChanged(address oldFeeRecipient, address newFeeRecipient); + + /// @notice Emitted when the fee multiplier is changed + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + + /// @notice Emitted when the max round delay is changed + event MaxRoundDelayChanged( + uint256 oldMaxRoundDelay, + uint256 newMaxRoundDelay + ); + + /// @notice Emitted when the max decrements is changed + event MaxDecrementsChanged( + uint256 oldMaxDecrements, + uint256 newMaxDecrements + ); + + /// @notice Emitted when the price is updated early and liquidated + event PriceUpdatedEarlyAndLiquidated( + address indexed borrower, + uint256 seizedAssets, + uint256 repaidAssets, + uint256 protocolFee, + uint256 liquidatorFee + ); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /** + * @notice Initialize the proxy with a price feed address + * @param _priceFeed Address of the Chainlink price feed to forward calls to + * @param _owner Address that will own this contract + * @param _morphoBlue Address of the Morpho Blue contract + * @param _chainlinkOracle Address of the Chainlink oracle contract + * @param _feeRecipient Address that will receive the OEV fees + * @param _feeMultiplier The fee multiplier for the OEV fees + * @param _maxRoundDelay The max round delay + * @param _maxDecrements The max decrements + */ + function initializeV2( + address _priceFeed, + address _owner, + address _morphoBlue, + address _chainlinkOracle, + address _feeRecipient, + uint16 _feeMultiplier, + uint256 _maxRoundDelay, + uint256 _maxDecrements + ) public reinitializer(2) { + require( + _priceFeed != address(0), + "ChainlinkOEVMorphoWrapper: price feed cannot be zero address" + ); + require( + _owner != address(0), + "ChainlinkOEVMorphoWrapper: owner cannot be zero address" + ); + require( + _chainlinkOracle != address(0), + "ChainlinkOEVMorphoWrapper: chainlink oracle cannot be zero address" + ); + require( + _feeRecipient != address(0), + "ChainlinkOEVMorphoWrapper: fee recipient cannot be zero address" + ); + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOEVMorphoWrapper: fee multiplier cannot be greater than MAX_BPS" + ); + require( + _maxRoundDelay > 0, + "ChainlinkOEVMorphoWrapper: max round delay cannot be zero" + ); + require( + _maxDecrements > 0, + "ChainlinkOEVMorphoWrapper: max decrements cannot be zero" + ); + __Ownable_init(); + + priceFeed = AggregatorV3Interface(_priceFeed); + morphoBlue = IMorphoBlue(_morphoBlue); + chainlinkOracle = IChainlinkOracle(_chainlinkOracle); + feeRecipient = _feeRecipient; + feeMultiplier = _feeMultiplier; + cachedRoundId = priceFeed.latestRound(); + maxRoundDelay = _maxRoundDelay; + maxDecrements = _maxDecrements; + + _transferOwnership(_owner); + } + + /** + * @notice Returns the number of decimals in the price feed + * @return The number of decimals + */ + function decimals() external view override returns (uint8) { + return priceFeed.decimals(); + } + + /** + * @notice Returns a description of the price feed + * @return The description string + */ + function description() external view override returns (string memory) { + return priceFeed.description(); + } + + /** + * @notice Returns the version number of the price feed + * @return The version number + */ + function version() external view override returns (uint256) { + return priceFeed.version(); + } + + /** + * @notice Returns data for a specific round + * @param _roundId The round ID to retrieve data for + * @return roundId The round ID + * @return answer The price reported in this round + * @return startedAt The timestamp when the round started + * @return updatedAt The timestamp when the round was updated + * @return answeredInRound The round ID in which the answer was computed + */ + function getRoundData( + uint80 _roundId + ) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed + .getRoundData(_roundId); + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + } + + /** + * @notice Returns data from the latest round, with OEV protection mechanism + * @dev If the latest round hasn't been paid for (via updatePriceEarlyAndLiquidate) and is recent, + * this function will return data from a previous round instead + * @return roundId The round ID + * @return answer The latest price + * @return startedAt The timestamp when the round started + * @return updatedAt The timestamp when the round was updated + * @return answeredInRound The round ID in which the answer was computed + */ + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed + .latestRoundData(); + + // The default behavior is to delay the price update unless someone has paid for the current round. + // If the current round is not too old (maxRoundDelay seconds) and hasn't been paid for, + // attempt to find the most recent valid round by checking previous rounds + if ( + roundId != cachedRoundId && + block.timestamp < updatedAt + maxRoundDelay + ) { + // start from the previous round + uint256 currentRoundId = roundId - 1; + + for (uint256 i = 0; i < maxDecrements && currentRoundId > 0; i++) { + try priceFeed.getRoundData(uint80(currentRoundId)) returns ( + uint80 r, + int256 a, + uint256 s, + uint256 u, + uint80 ar + ) { + // previous round data found, update the round data + roundId = r; + answer = a; + startedAt = s; + updatedAt = u; + answeredInRound = ar; + break; + } catch { + // previous round data not found, continue to the next decrement + currentRoundId--; + } + } + } + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + } + + /** + * @notice Returns the latest round ID + * @dev Falls back to extracting round ID from latestRoundData if latestRound() is not supported + * @return The latest round ID + */ + function latestRound() external view override returns (uint256) { + try priceFeed.latestRound() returns (uint256 round) { + return round; + } catch { + // Fallback: extract round ID from latestRoundData + (uint80 roundId, , , , ) = priceFeed.latestRoundData(); + return uint256(roundId); + } + } + + /** + * @notice Sets the fee recipient address + * @param _feeRecipient The new fee recipient address + */ + function setFeeRecipient(address _feeRecipient) external onlyOwner { + require( + _feeRecipient != address(0), + "ChainlinkOEVMorphoWrapper: fee recipient cannot be zero address" + ); + + address oldFeeRecipient = feeRecipient; + feeRecipient = _feeRecipient; + + emit FeeRecipientChanged(oldFeeRecipient, _feeRecipient); + } + + /** + * @notice Sets the fee multiplier for OEV fees + * @param _feeMultiplier The new fee multiplier in basis points (must be <= MAX_BPS) + */ + function setFeeMultiplier(uint16 _feeMultiplier) external onlyOwner { + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOEVMorphoWrapper: fee multiplier cannot be greater than MAX_BPS" + ); + uint16 oldFeeMultiplier = feeMultiplier; + feeMultiplier = _feeMultiplier; + emit FeeMultiplierChanged(oldFeeMultiplier, _feeMultiplier); + } + + /** + * @notice Sets the max round delay in seconds + * @param _maxRoundDelay The new max round delay (must be > 0) + */ + function setMaxRoundDelay(uint256 _maxRoundDelay) external onlyOwner { + require( + _maxRoundDelay > 0, + "ChainlinkOEVMorphoWrapper: max round delay cannot be zero" + ); + uint256 oldMaxRoundDelay = maxRoundDelay; + maxRoundDelay = _maxRoundDelay; + emit MaxRoundDelayChanged(oldMaxRoundDelay, _maxRoundDelay); + } + + /** + * @notice Sets the max number of decrements to search previous rounds + * @param _maxDecrements The new max decrements (must be > 0) + */ + function setMaxDecrements(uint256 _maxDecrements) external onlyOwner { + require( + _maxDecrements > 0, + "ChainlinkOEVMorphoWrapper: max decrements cannot be zero" + ); + uint256 oldMaxDecrements = maxDecrements; + maxDecrements = _maxDecrements; + emit MaxDecrementsChanged(oldMaxDecrements, _maxDecrements); + } + + /// @notice Validate the round data from Chainlink + /// @param roundId The round ID to validate + /// @param answer The price to validate + /// @param updatedAt The timestamp when the round was updated + /// @param answeredInRound The round ID in which the answer was computed + function _validateRoundData( + uint80 roundId, + int256 answer, + uint256 updatedAt, + uint80 answeredInRound + ) internal pure { + require(answer > 0, "Chainlink price cannot be lower or equal to 0"); + require(updatedAt != 0, "Round is in incompleted state"); + require(answeredInRound >= roundId, "Stale price"); + } + + /** + * @notice Updates the cached round ID to allow early access to the latest price and executes a liquidation + * @dev This function collects a fee from the caller, updates the cached price, and performs the liquidation on Morpho Blue + * @dev The actual repayment amount is calculated by Morpho based on seizedAssets, oracle price, and liquidation incentive + * @param marketParams The Morpho market parameters identifying the market + * @param borrower The address of the borrower to liquidate + * @param seizedAssets The amount of collateral assets to seize from the borrower + * @param maxRepayAmount The maximum amount of loan tokens the liquidator is willing to repay (slippage protection) + */ + function updatePriceEarlyAndLiquidate( + MarketParams memory marketParams, + address borrower, + uint256 seizedAssets, + uint256 maxRepayAmount + ) external { + // ensure the borrower is not the zero address + require( + borrower != address(0), + "ChainlinkOEVMorphoWrapper: borrower cannot be zero address" + ); + + // ensure the seized assets is greater than zero + require( + seizedAssets > 0, + "ChainlinkOEVMorphoWrapper: seized assets cannot be zero" + ); + + // ensure max repay amount is greater than zero + require( + maxRepayAmount > 0, + "ChainlinkOEVMorphoWrapper: max repay amount cannot be zero" + ); + + require( + address( + IMorphoChainlinkOracleV2(marketParams.oracle).BASE_FEED_1() + ) == address(this), + "ChainlinkOEVMorphoWrapper: oracle must be the same as the base feed 1" + ); + + // get the latest round data and update cached round id + int256 collateralAnswer; + { + ( + uint80 roundId, + int256 answer, + , + uint256 updatedAt, + uint80 answeredInRound + ) = priceFeed.latestRoundData(); + + // validate the round data + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + + // update the cached round id + cachedRoundId = roundId; + collateralAnswer = answer; + } + + // Execute liquidation + uint256 actualSeizedAssets; + uint256 actualRepaidAssets; + { + EIP20Interface loanToken = EIP20Interface(marketParams.loanToken); + + // Morpho will pull the actual amount needed, and we'll return any excess + loanToken.transferFrom(msg.sender, address(this), maxRepayAmount); + + loanToken.approve(address(morphoBlue), maxRepayAmount); + (actualSeizedAssets, actualRepaidAssets) = morphoBlue.liquidate( + marketParams, + borrower, + seizedAssets, + 0, + "" + ); + + require( + actualRepaidAssets <= maxRepayAmount, + "ChainlinkOEVMorphoWrapper: repaid amount exceeds maximum" + ); + + // return any excess loan tokens to the liquidator + uint256 excessLoanTokens = maxRepayAmount - actualRepaidAssets; + if (excessLoanTokens > 0) { + loanToken.transfer(msg.sender, excessLoanTokens); + } + } + + // Calculate the split of collateral between liquidator and protocol + ( + uint256 liquidatorFee, + uint256 protocolFee + ) = _calculateCollateralSplit( + actualRepaidAssets, + collateralAnswer, + actualSeizedAssets, + marketParams + ); + + // transfer the liquidator's payment (repayment + bonus) to the liquidator + EIP20Interface(marketParams.collateralToken).transfer( + msg.sender, + liquidatorFee + ); + + // transfer the remainder to the fee recipient + EIP20Interface(marketParams.collateralToken).transfer( + feeRecipient, + protocolFee + ); + + emit PriceUpdatedEarlyAndLiquidated( + borrower, + actualSeizedAssets, + actualRepaidAssets, + protocolFee, + liquidatorFee + ); + } + + /// @notice Get the loan token price from ChainlinkOracle + /// @dev Gets the feed for the loan token and scales the price similar to ChainlinkOracle + /// @param loanToken The loan token interface + /// @return The price scaled to 1e18 and adjusted for token decimals + function _getLoanTokenPrice( + EIP20Interface loanToken + ) private view returns (uint256) { + // Get the price feed for the loan token + AggregatorV3Interface loanFeed = chainlinkOracle.getFeed( + loanToken.symbol() + ); + + // Get the latest price from the feed + (, int256 loanAnswer, , , ) = loanFeed.latestRoundData(); + require( + loanAnswer > 0, + "ChainlinkOEVMorphoWrapper: invalid loan token price" + ); + + // Scale feed decimals to 18 + uint256 decimalDelta = uint256(18) - uint256(loanFeed.decimals()); + uint256 loanPricePerUnit = uint256(loanAnswer); + if (decimalDelta > 0) { + loanPricePerUnit = loanPricePerUnit * (10 ** decimalDelta); + } + + // Adjust for token decimals (same logic as ChainlinkOracle) + uint256 loanDecimalDelta = uint256(18) - uint256(loanToken.decimals()); + if (loanDecimalDelta > 0) { + return loanPricePerUnit * (10 ** loanDecimalDelta); + } + return loanPricePerUnit; + } + + /// @notice Calculate the fully adjusted collateral token price + /// @dev Scales Chainlink feed decimals to 18, then adjusts for token decimals + /// @param collateralAnswer The raw price from Chainlink + /// @param underlyingCollateral The collateral token interface + /// @return The price scaled to 1e18 and adjusted for token decimals + function _getCollateralTokenPrice( + int256 collateralAnswer, + EIP20Interface underlyingCollateral + ) private view returns (uint256) { + uint256 decimalDelta = uint256(18) - uint256(priceFeed.decimals()); + uint256 collateralPricePerUnit = uint256(collateralAnswer); + if (decimalDelta > 0) { + collateralPricePerUnit = + collateralPricePerUnit * + (10 ** decimalDelta); + } + + // Adjust for token decimals (same logic as ChainlinkOracle) + uint256 collateralDecimalDelta = uint256(18) - + uint256(underlyingCollateral.decimals()); + if (collateralDecimalDelta > 0) { + return collateralPricePerUnit * (10 ** collateralDecimalDelta); + } + return collateralPricePerUnit; + } + + /// @notice Calculate the split of seized collateral between liquidator and fee recipient + /// @param repayAmount The amount of loan tokens being repaid + /// @param collateralAnswer The raw price from Chainlink for the collateral + /// @param collateralReceived The amount of collateral tokens seized + /// @param marketParams The Morpho market parameters + /// @return liquidatorFee The amount of collateral to send to the liquidator (repayment + bonus) + /// @return protocolFee The amount of collateral to send to the fee recipient (remainder) + function _calculateCollateralSplit( + uint256 repayAmount, + int256 collateralAnswer, + uint256 collateralReceived, + MarketParams memory marketParams + ) internal view returns (uint256 liquidatorFee, uint256 protocolFee) { + uint256 loanTokenPrice = _getLoanTokenPrice( + EIP20Interface(marketParams.loanToken) + ); + uint256 collateralTokenPrice = _getCollateralTokenPrice( + collateralAnswer, + EIP20Interface(marketParams.collateralToken) + ); + + uint256 usdNormalizer = 10 ** PRICE_MANTISSA_DECIMALS; // 1e18 + uint256 repayUSD = (repayAmount * loanTokenPrice) / usdNormalizer; + uint256 collateralUSD = (collateralReceived * collateralTokenPrice) / + usdNormalizer; + + // If collateral is worth less than repayment, liquidator gets all collateral + if (collateralUSD <= repayUSD) { + liquidatorFee = collateralReceived; + protocolFee = 0; + return (liquidatorFee, protocolFee); + } + + // Liquidator gets the repayment amount + bonus (remainder * feeMultiplier) + uint256 liquidatorUSD = repayUSD + + ((collateralUSD - repayUSD) * uint256(feeMultiplier)) / + MAX_BPS; + + // Convert back to collateral token amount + liquidatorFee = (liquidatorUSD * usdNormalizer) / collateralTokenPrice; + + protocolFee = collateralReceived - liquidatorFee; + } +} diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol new file mode 100644 index 000000000..27529206b --- /dev/null +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -0,0 +1,559 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {Ownable} from "@openzeppelin-contracts/contracts/access/Ownable.sol"; +import {AggregatorV3Interface} from "./AggregatorV3Interface.sol"; +import {MErc20Storage, MTokenInterface, MErc20Interface} from "../MTokenInterfaces.sol"; +import {MToken} from "../MToken.sol"; +import {EIP20Interface} from "../EIP20Interface.sol"; +import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; + +/** + * @title ChainlinkOEVWrapper + * @notice A wrapper for Chainlink price feeds that allows early updates for liquidation + * @dev This contract implements the AggregatorV3Interface and adds OEV (Oracle Extractable Value) functionality + */ +contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { + /// @notice The maximum basis points for the fee multiplier + uint16 public constant MAX_BPS = 10000; + + /// @notice Chainlink feed decimals (USD feeds use 8 decimals) + uint8 private constant CHAINLINK_FEED_DECIMALS = 8; + + /// @notice Price mantissa decimals (used by ChainlinkOracle) + uint8 private constant PRICE_MANTISSA_DECIMALS = 18; + + /// @notice The ChainlinkOracle contract + IChainlinkOracle public immutable chainlinkOracle; + + /// @notice The Chainlink price feed this proxy forwards to + AggregatorV3Interface public priceFeed; + + /// @notice The address that will receive the OEV fees + address public feeRecipient; + + /// @notice The fee multiplier for the OEV fees, to be paid to the liquidator + /// @dev Represented as a percentage + uint16 public feeMultiplier; + + /// @notice The last cached round id + uint256 public cachedRoundId; + + /// @notice The max round delay + uint256 public maxRoundDelay; + + /// @notice The max decrements + uint256 public maxDecrements; + + /// @notice Emitted when the fee recipient is changed + event FeeRecipientChanged(address oldFeeRecipient, address newFeeRecipient); + + /// @notice Emitted when the fee multiplier is changed + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + + /// @notice Emitted when the max round delay is changed + event MaxRoundDelayChanged( + uint256 oldMaxRoundDelay, + uint256 newMaxRoundDelay + ); + + /// @notice Emitted when the max decrements is changed + event MaxDecrementsChanged( + uint256 oldMaxDecrements, + uint256 newMaxDecrements + ); + + /// @notice Emitted when the price is updated early and liquidated + event PriceUpdatedEarlyAndLiquidated( + address indexed borrower, + uint256 repayAmount, + address mTokenCollateral, + address mTokenLoan, + uint256 protocolFee, + uint256 liquidatorFee + ); + + /** + * @notice Contract constructor + * @param _priceFeed Address of the Chainlink price feed to forward calls to + * @param _owner Address that will own this contract + * @param _feeMultiplier The fee multiplier for the OEV fees + * @param _maxRoundDelay The max round delay + * @param _maxDecrements The max decrements + */ + constructor( + address _priceFeed, + address _owner, + address _chainlinkOracle, + address _feeRecipient, + uint16 _feeMultiplier, + uint256 _maxRoundDelay, + uint256 _maxDecrements + ) { + require( + _priceFeed != address(0), + "ChainlinkOEVWrapper: price feed cannot be zero address" + ); + require( + _owner != address(0), + "ChainlinkOEVWrapper: owner cannot be zero address" + ); + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOEVWrapper: fee multiplier cannot be greater than MAX_BPS" + ); + require( + _maxRoundDelay > 0, + "ChainlinkOEVWrapper: max round delay cannot be zero" + ); + require( + _maxDecrements > 0, + "ChainlinkOEVWrapper: max decrements cannot be zero" + ); + require( + _chainlinkOracle != address(0), + "ChainlinkOEVWrapper: chainlink oracle cannot be zero address" + ); + require( + _feeRecipient != address(0), + "ChainlinkOEVWrapper: fee recipient cannot be zero address" + ); + + priceFeed = AggregatorV3Interface(_priceFeed); + feeMultiplier = _feeMultiplier; + cachedRoundId = priceFeed.latestRound(); + maxRoundDelay = _maxRoundDelay; + maxDecrements = _maxDecrements; + chainlinkOracle = IChainlinkOracle(_chainlinkOracle); + feeRecipient = _feeRecipient; + + _transferOwnership(_owner); + } + + /** + * @notice Returns the number of decimals in the price feed + * @return The number of decimals + */ + function decimals() external view override returns (uint8) { + return priceFeed.decimals(); + } + + /** + * @notice Returns a description of the price feed + * @return The description string + */ + function description() external view override returns (string memory) { + return priceFeed.description(); + } + + /** + * @notice Returns the version number of the price feed + * @return The version number + */ + function version() external view override returns (uint256) { + return priceFeed.version(); + } + + /** + * @notice Returns data for a specific round + * @param _roundId The round ID to retrieve data for + * @return roundId The round ID + * @return answer The price reported in this round + * @return startedAt The timestamp when the round started + * @return updatedAt The timestamp when the round was updated + * @return answeredInRound The round ID in which the answer was computed + */ + function getRoundData( + uint80 _roundId + ) + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed + .getRoundData(_roundId); + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + } + + /** + * @notice Returns data from the latest round, with OEV protection mechanism + * @dev If the latest round hasn't been paid for (via updatePriceEarlyAndLiquidate) and is recent, + * this function will return data from a previous round instead + * @return roundId The round ID + * @return answer The latest price + * @return startedAt The timestamp when the round started + * @return updatedAt The timestamp when the round was updated + * @return answeredInRound The round ID in which the answer was computed + */ + function latestRoundData() + external + view + override + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) + { + (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed + .latestRoundData(); + + // The default behavior is to delay the price update unless someone has paid for the current round. + // If the current round is not too old (maxRoundDelay seconds) and hasn't been paid for, + // attempt to find the most recent valid round by checking previous rounds + if ( + roundId != cachedRoundId && + block.timestamp < updatedAt + maxRoundDelay + ) { + // start from the previous round + uint256 currentRoundId = roundId - 1; + + for (uint256 i = 0; i < maxDecrements && currentRoundId > 0; i++) { + try priceFeed.getRoundData(uint80(currentRoundId)) returns ( + uint80 r, + int256 a, + uint256 s, + uint256 u, + uint80 ar + ) { + // previous round data found, update the round data + roundId = r; + answer = a; + startedAt = s; + updatedAt = u; + answeredInRound = ar; + break; + } catch { + // previous round data not found, continue to the next decrement + currentRoundId--; + } + } + } + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + } + + /** + * @notice Returns the latest round ID + * @dev Falls back to extracting round ID from latestRoundData if latestRound() is not supported + * @return The latest round ID + */ + function latestRound() external view override returns (uint256) { + try priceFeed.latestRound() returns (uint256 round) { + return round; + } catch { + // Fallback: extract round ID from latestRoundData + (uint80 roundId, , , , ) = priceFeed.latestRoundData(); + return uint256(roundId); + } + } + + /** + * @notice Sets the fee multiplier for OEV fees + * @param _feeMultiplier The new fee multiplier in basis points (must be <= MAX_BPS) + */ + function setFeeMultiplier(uint16 _feeMultiplier) external onlyOwner { + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOEVWrapper: fee multiplier cannot be greater than MAX_BPS" + ); + uint16 oldFeeMultiplier = feeMultiplier; + feeMultiplier = _feeMultiplier; + emit FeeMultiplierChanged(oldFeeMultiplier, _feeMultiplier); + } + + /** + * @notice Sets the max round delay in seconds + * @param _maxRoundDelay The new max round delay (must be > 0) + */ + function setMaxRoundDelay(uint256 _maxRoundDelay) external onlyOwner { + require( + _maxRoundDelay > 0, + "ChainlinkOEVWrapper: max round delay cannot be zero" + ); + uint256 oldMaxRoundDelay = maxRoundDelay; + maxRoundDelay = _maxRoundDelay; + emit MaxRoundDelayChanged(oldMaxRoundDelay, _maxRoundDelay); + } + + /** + * @notice Sets the max number of decrements to search previous rounds + * @param _maxDecrements The new max decrements (must be > 0) + */ + function setMaxDecrements(uint256 _maxDecrements) external onlyOwner { + require( + _maxDecrements > 0, + "ChainlinkOEVWrapper: max decrements cannot be zero" + ); + uint256 oldMaxDecrements = maxDecrements; + maxDecrements = _maxDecrements; + emit MaxDecrementsChanged(oldMaxDecrements, _maxDecrements); + } + + /** + * @notice Sets the fee recipient address + * @param _feeRecipient The new fee recipient address + */ + function setFeeRecipient(address _feeRecipient) external onlyOwner { + require( + _feeRecipient != address(0), + "ChainlinkOEVWrapper: fee recipient cannot be zero address" + ); + + address oldFeeRecipient = feeRecipient; + feeRecipient = _feeRecipient; + + emit FeeRecipientChanged(oldFeeRecipient, _feeRecipient); + } + + /** + * @notice Allows the contract to receive ETH (needed for mWETH redemption which unwraps to ETH) + */ + receive() external payable {} + + /** + * @notice Updates the cached round ID to allow early access to the latest price and executes a liquidation + * @dev This function collects a fee from the caller, updates the cached price, and performs the liquidation + * @param borrower The address of the borrower to liquidate + * @param repayAmount The amount to repay on behalf of the borrower + * @param mTokenCollateral The mToken market for the collateral token + * @param mTokenLoan The mToken market for the loan token, against which to liquidate + */ + function updatePriceEarlyAndLiquidate( + address borrower, + uint256 repayAmount, + address mTokenCollateral, + address mTokenLoan + ) external { + // ensure the repay amount is greater than zero + require( + repayAmount > 0, + "ChainlinkOEVWrapper: repay amount cannot be zero" + ); + + // ensure the borrower is not the zero address + require( + borrower != address(0), + "ChainlinkOEVWrapper: borrower cannot be zero address" + ); + + // ensure the mToken is not the zero address + require( + mTokenCollateral != address(0), + "ChainlinkOEVWrapper: mToken collateral cannot be zero address" + ); + + // ensure the mToken loan is not the zero address + require( + mTokenLoan != address(0), + "ChainlinkOEVWrapper: mToken loan cannot be zero address" + ); + + // get the loan underlying token (the token being repaid) + EIP20Interface underlyingLoan = EIP20Interface( + MErc20Storage(mTokenLoan).underlying() + ); + + // get the collateral underlying token (the token being seized) + EIP20Interface underlyingCollateral = EIP20Interface( + MErc20Storage(mTokenCollateral).underlying() + ); + + MTokenInterface mTokenCollateralInterface = MTokenInterface( + mTokenCollateral + ); + + require( + address(chainlinkOracle.getFeed(underlyingCollateral.symbol())) == + address(this), + "ChainlinkOEVWrapper: chainlink oracle feed does not match" + ); + + // transfer the loan token (to repay the borrow) from the liquidator to this contract + underlyingLoan.transferFrom(msg.sender, address(this), repayAmount); + + // get the latest round data and update cached round id + int256 collateralAnswer; + { + ( + uint80 roundId, + int256 answer, + , + uint256 updatedAt, + uint80 answeredInRound + ) = priceFeed.latestRoundData(); + + // validate the round data + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + + // update the cached round id + cachedRoundId = roundId; + collateralAnswer = answer; + } + + // execute liquidation + uint256 collateralSeized = _executeLiquidation( + borrower, + repayAmount, + mTokenCollateralInterface, + mTokenLoan, + underlyingLoan + ); + + // Calculate the split of collateral between liquidator and protocol + ( + uint256 liquidatorFee, + uint256 protocolFee + ) = _calculateCollateralSplit( + repayAmount, + collateralAnswer, + collateralSeized, + mTokenLoan, + underlyingCollateral + ); + + // transfer the liquidator's payment (repayment + bonus) to the liquidator + mTokenCollateralInterface.transfer(msg.sender, liquidatorFee); + + // transfer the remainder to the fee recipient + mTokenCollateralInterface.transfer(feeRecipient, protocolFee); + + emit PriceUpdatedEarlyAndLiquidated( + borrower, + repayAmount, + mTokenCollateral, + mTokenLoan, + protocolFee, + liquidatorFee + ); + } + + /// @notice Validate the round data from Chainlink + /// @param roundId The round ID to validate + /// @param answer The price to validate + /// @param updatedAt The timestamp when the round was updated + /// @param answeredInRound The round ID in which the answer was computed + function _validateRoundData( + uint80 roundId, + int256 answer, + uint256 updatedAt, + uint80 answeredInRound + ) internal pure { + require(answer > 0, "Chainlink price cannot be lower or equal to 0"); + require(updatedAt != 0, "Round is in incompleted state"); + require(answeredInRound >= roundId, "Stale price"); + } + + /// @notice Calculate the fully adjusted collateral token price + /// @dev Scales Chainlink feed decimals to 18, then adjusts for token decimals + /// @param collateralAnswer The raw price from Chainlink + /// @param underlyingCollateral The collateral token interface + /// @return The price scaled to 1e18 and adjusted for token decimals + function _getCollateralTokenPrice( + int256 collateralAnswer, + EIP20Interface underlyingCollateral + ) private view returns (uint256) { + uint256 decimalDelta = uint256(18) - uint256(priceFeed.decimals()); + uint256 collateralPricePerUnit = uint256(collateralAnswer); + if (decimalDelta > 0) { + collateralPricePerUnit = + collateralPricePerUnit * + (10 ** decimalDelta); + } + + uint256 collateralDecimalDelta = uint256(18) - + uint256(underlyingCollateral.decimals()); + if (collateralDecimalDelta > 0) { + return collateralPricePerUnit * (10 ** collateralDecimalDelta); + } + return collateralPricePerUnit; + } + + /// @notice Execute liquidation + /// @param borrower The address of the borrower to liquidate + /// @param repayAmount The amount to repay on behalf of the borrower + /// @param mTokenCollateral The mToken market for the collateral token + /// @param _mTokenLoan The mToken market for the loan token + /// @param underlyingLoan The underlying loan token interface + /// @return collateralSeized The amount of mToken collateral seized + function _executeLiquidation( + address borrower, + uint256 repayAmount, + MTokenInterface mTokenCollateral, + address _mTokenLoan, + EIP20Interface underlyingLoan + ) internal returns (uint256 collateralSeized) { + uint256 mTokenCollateralBalanceBefore = mTokenCollateral.balanceOf( + address(this) + ); + underlyingLoan.approve(_mTokenLoan, repayAmount); + require( + MErc20Interface(_mTokenLoan).liquidateBorrow( + borrower, + repayAmount, + mTokenCollateral + ) == 0, + "ChainlinkOEVWrapper: liquidation failed" + ); + + collateralSeized = + mTokenCollateral.balanceOf(address(this)) - + mTokenCollateralBalanceBefore; + } + + /// @notice Calculate the split of seized collateral between liquidator and fee recipient + /// @param repayAmount The amount of loan tokens being repaid + /// @param collateralSeized The amount of collateral tokens seized + /// @param mTokenLoan The mToken for the loan being repaid + /// @param underlyingCollateral The underlying collateral token interface + /// @return liquidatorFee The amount of collateral to send to the liquidator (repayment + bonus) + /// @return protocolFee The amount of collateral to send to the fee recipient (remainder) + function _calculateCollateralSplit( + uint256 repayAmount, + int256 collateralAnswer, + uint256 collateralSeized, + address mTokenLoan, + EIP20Interface underlyingCollateral + ) internal view returns (uint256 liquidatorFee, uint256 protocolFee) { + uint256 loanPrice = chainlinkOracle.getUnderlyingPrice( + MToken(mTokenLoan) + ); + uint256 collateralPrice = _getCollateralTokenPrice( + collateralAnswer, + underlyingCollateral + ); + + uint256 usdNormalizer = 10 ** PRICE_MANTISSA_DECIMALS; // 1e18 + uint256 repayUSD = (repayAmount * loanPrice) / usdNormalizer; + uint256 collateralUSD = (collateralSeized * collateralPrice) / + usdNormalizer; + + // If collateral is worth less than repayment, liquidator gets all collateral + if (collateralUSD <= repayUSD) { + liquidatorFee = collateralSeized; + protocolFee = 0; + return (liquidatorFee, protocolFee); + } + + // Liquidator gets the repayment amount + bonus (remainder * feeMultiplier) + uint256 liquidatorUSD = repayUSD + + ((collateralUSD - repayUSD) * uint256(feeMultiplier)) / + MAX_BPS; + + // Convert back to collateral token amount + liquidatorFee = (liquidatorUSD * usdNormalizer) / collateralPrice; + + protocolFee = collateralSeized - liquidatorFee; + } +} diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOracleProxy.sol deleted file mode 100644 index 6ab3957a7..000000000 --- a/src/oracles/ChainlinkOracleProxy.sol +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity 0.8.19; - -import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; -import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; -import "./AggregatorV3Interface.sol"; - -/** - * @title ChainlinkOracleProxy - * @notice A TransparentUpgradeableProxy compliant contract that implements AggregatorV3Interface - * and forwards calls to a configurable Chainlink price feed - */ -contract ChainlinkOracleProxy is - Initializable, - OwnableUpgradeable, - AggregatorV3Interface -{ - /// @notice The Chainlink price feed this proxy forwards to - AggregatorV3Interface public priceFeed; - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /** - * @notice Initialize the proxy with a price feed address - * @param _priceFeed Address of the Chainlink price feed to forward calls to - * @param _owner Address that will own this contract - */ - function initialize(address _priceFeed, address _owner) public initializer { - require( - _priceFeed != address(0), - "ChainlinkOracleProxy: price feed cannot be zero address" - ); - require( - _owner != address(0), - "ChainlinkOracleProxy: owner cannot be zero address" - ); - - __Ownable_init(); - - priceFeed = AggregatorV3Interface(_priceFeed); - _transferOwnership(_owner); - } - - // AggregatorV3Interface implementation - forwards all calls to the configured price feed - - function decimals() external view override returns (uint8) { - return priceFeed.decimals(); - } - - function description() external view override returns (string memory) { - return priceFeed.description(); - } - - function version() external view override returns (uint256) { - return priceFeed.version(); - } - - function getRoundData( - uint80 _roundId - ) - external - view - override - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) - { - (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed - .getRoundData(_roundId); - _validateRoundData(roundId, answer, updatedAt, answeredInRound); - } - - function latestRoundData() - external - view - override - returns ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) - { - (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed - .latestRoundData(); - _validateRoundData(roundId, answer, updatedAt, answeredInRound); - } - - function latestRound() external view override returns (uint256) { - try priceFeed.latestRound() returns (uint256 round) { - return round; - } catch { - // Fallback: extract round ID from latestRoundData - (uint80 roundId, , , , ) = priceFeed.latestRoundData(); - return uint256(roundId); - } - } - - /// @notice Validate the round data from Chainlink - /// @param roundId The round ID to validate - /// @param answer The price to validate - /// @param updatedAt The timestamp when the round was updated - /// @param answeredInRound The round ID in which the answer was computed - function _validateRoundData( - uint80 roundId, - int256 answer, - uint256 updatedAt, - uint80 answeredInRound - ) internal pure { - require(answer > 0, "Chainlink price cannot be lower or equal to 0"); - require(updatedAt != 0, "Round is in incompleted state"); - require(answeredInRound >= roundId, "Stale price"); - } -} diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol new file mode 100644 index 000000000..208c2f196 --- /dev/null +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -0,0 +1,556 @@ +pragma solidity 0.8.19; + +import "@forge-std/Test.sol"; +import {IERC20Metadata as IERC20} from "@openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; +import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; +import {ChainlinkOEVMorphoWrapper} from "@protocol/oracles/ChainlinkOEVMorphoWrapper.sol"; +import {IMorphoBlue} from "@protocol/morpho/IMorphoBlue.sol"; +import {MErc20} from "@protocol/MErc20.sol"; +import {IMetaMorpho, MarketParams, MarketAllocation} from "@protocol/morpho/IMetaMorpho.sol"; +import {ChainlinkOracleConfigs} from "@proposals/ChainlinkOracleConfigs.sol"; +import {OEVProtocolFeeRedeemer} from "@protocol/OEVProtocolFeeRedeemer.sol"; + +contract ChainlinkOEVMorphoWrapperIntegrationTest is + PostProposalCheck, + ChainlinkOracleConfigs +{ + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + event PriceUpdatedEarlyAndLiquidated( + address indexed sender, + address indexed borrower, + uint256 seizedAssets, + uint256 repaidAssets, + uint256 fee + ); + + ChainlinkOEVMorphoWrapper[] public wrappers; + OEVProtocolFeeRedeemer public redeemer; + + // Test actors + address internal constant BORROWER = + address(uint160(uint256(keccak256(abi.encodePacked("BORROWER"))))); + address internal constant LIQUIDATOR = + address(uint160(uint256(keccak256(abi.encodePacked("LIQUIDATOR"))))); + + function setUp() public override { + uint256 primaryForkId = vm.envUint("PRIMARY_FORK_ID"); + super.setUp(); + vm.selectFork(primaryForkId); + + // Get redeemer contract from addresses + redeemer = OEVProtocolFeeRedeemer( + payable(addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER")) + ); + + // Resolve morpho wrappers from shared morpho oracle configurations + MorphoOracleConfig[] + memory morphoConfigs = getMorphoOracleConfigurations(block.chainid); + for (uint256 i = 0; i < morphoConfigs.length; i++) { + string memory wrapperName = string( + abi.encodePacked(morphoConfigs[i].proxyName, "_ORACLE_PROXY") + ); + if (addresses.isAddressSet(wrapperName)) { + wrappers.push( + ChainlinkOEVMorphoWrapper(addresses.getAddress(wrapperName)) + ); + } + } + } + + function testSetFeeMultiplier() public { + uint16 newMultiplier = 100; // 1% + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + uint16 originalMultiplier = wrapper.feeMultiplier(); + vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); + vm.expectEmit(address(wrapper)); + emit FeeMultiplierChanged(originalMultiplier, newMultiplier); + wrapper.setFeeMultiplier(newMultiplier); + assertEq( + wrapper.feeMultiplier(), + newMultiplier, + "Fee multiplier not updated" + ); + } + } + + function testSetFeeMultiplierRevertNonOwner() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.expectRevert("Ownable: caller is not the owner"); + wrapper.setFeeMultiplier(1); + } + } + + function testGetRoundData() public { + uint80 roundId = 1; + int256 mockPrice = 3_000e8; + uint256 mockTimestamp = block.timestamp; + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().getRoundData.selector, + roundId + ), + abi.encode( + roundId, + mockPrice, + uint256(0), + mockTimestamp, + roundId + ) + ); + + ( + uint80 returnedRoundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = wrapper.getRoundData(roundId); + + assertEq(returnedRoundId, roundId); + assertEq(answer, mockPrice); + assertEq(startedAt, 0); + assertEq(updatedAt, mockTimestamp); + assertEq(answeredInRound, roundId); + } + } + + function testLatestRoundDataRevertOnChainlinkPriceIsZero() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + uint256 ts = vm.getBlockTimestamp(); + vm.warp(ts + uint256(wrapper.maxRoundDelay())); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode(uint80(1), int256(0), uint256(0), ts, uint80(1)) + ); + vm.expectRevert(); + wrapper.latestRoundData(); + } + } + + function testLatestRoundDataRevertOnIncompleteRoundState() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), + int256(3_000e8), + uint256(0), + uint256(0), + uint80(1) + ) + ); + vm.expectRevert(); + wrapper.latestRoundData(); + } + } + + function testLatestRoundDataRevertOnStalePriceData() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + uint256 ts = vm.getBlockTimestamp(); + vm.warp(ts + uint256(wrapper.maxRoundDelay())); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(2), + int256(3_000e8), + uint256(0), + ts, + uint80(1) + ) + ); + vm.expectRevert(); + wrapper.latestRoundData(); + } + } + + function testUpdatePriceEarlyAndLiquidate_WELL() public { + _testLiquidation( + addresses.getAddress("CHAINLINK_WELL_USD_ORACLE_PROXY"), + addresses.getAddress("xWELL_PROXY"), + addresses.getAddress("MORPHO_CHAINLINK_WELL_USD_ORACLE"), + 0.625e18, + 1_000_000e18, // 1M WELL tokens (~$10k at $0.01) + 10_000e18 + ); + } + + function testUpdatePriceEarlyAndLiquidate_stkWELL() public { + _testLiquidation( + addresses.getAddress("CHAINLINK_stkWELL_USD_ORACLE_PROXY"), + addresses.getAddress("STK_GOVTOKEN_PROXY"), + addresses.getAddress("MORPHO_CHAINLINK_stkWELL_USD_ORACLE"), + 0.625e18, + 1_000_000e18, // 1M stkWELL tokens + 10_000e18 + ); + } + + function testUpdatePriceEarlyAndLiquidate_MAMO() public { + _testLiquidation( + addresses.getAddress("CHAINLINK_MAMO_USD_ORACLE_PROXY"), + addresses.getAddress("MAMO"), + addresses.getAddress("MORPHO_CHAINLINK_MAMO_USD_ORACLE"), + 0.385e18, + 250_000e18, // Scale up collateral (5x from 50k to match WELL's economic value) + 2_500e18 // Scale up seized amount proportionally (MAMO is 4x WELL price, so 10k/4 = 2.5k) + ); + } + + function _testLiquidation( + address wrapperAddr, + address collToken, + address oracleAddr, + uint256 lltv, + uint256 collateralAmount, + uint256 seized + ) internal { + ChainlinkOEVMorphoWrapper wrapper = ChainlinkOEVMorphoWrapper( + wrapperAddr + ); + address loanToken = addresses.getAddress("USDC"); + uint256 borrowAmount = 50e6; // $50 USDC (6 decimals) + + // Setup market params + MarketParams memory params = MarketParams({ + loanToken: loanToken, + collateralToken: collToken, + oracle: oracleAddr, + irm: addresses.getAddress("MORPHO_ADAPTIVE_CURVE_IRM"), + lltv: lltv + }); + + // Setup Morpho Blue + IMorphoBlue morpho = IMorphoBlue(addresses.getAddress("MORPHO_BLUE")); + + // Setup borrower position + deal(collToken, BORROWER, collateralAmount); + vm.startPrank(BORROWER); + IERC20(collToken).approve(address(morpho), collateralAmount); + morpho.supplyCollateral(params, collateralAmount, BORROWER, ""); + + morpho.borrow(params, borrowAmount, 0, BORROWER, BORROWER); + vm.stopPrank(); + + // Mock price crash + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector(bytes4(keccak256("latestRoundData()"))), + abi.encode( + uint80(777), + int256(1), + uint256(0), + block.timestamp, + uint80(777) + ) + ); + + // Execute liquidation + _executeLiquidation( + wrapper, + params, + loanToken, + borrowAmount, + seized, + collToken + ); + } + + function _executeLiquidation( + ChainlinkOEVMorphoWrapper wrapper, + MarketParams memory params, + address loanToken, + uint256 borrowAmount, + uint256 seized, + address collToken + ) internal { + deal(loanToken, LIQUIDATOR, borrowAmount); + vm.startPrank(LIQUIDATOR); + IERC20(loanToken).approve(address(wrapper), borrowAmount); + + uint256 liqLoanBefore = IERC20(loanToken).balanceOf(LIQUIDATOR); + uint256 liqCollBefore = IERC20(collToken).balanceOf(LIQUIDATOR); + uint256 redeemerCollBefore = IERC20(collToken).balanceOf( + address(redeemer) + ); + + vm.recordLogs(); + wrapper.updatePriceEarlyAndLiquidate( + params, + BORROWER, + seized, + borrowAmount + ); + vm.stopPrank(); + + // Parse event to get protocol fee + (uint256 protocolFee, ) = _parseLiquidationEvent(); + + // Assertions + assertEq(wrapper.cachedRoundId(), 777); + assertGt( + liqLoanBefore - IERC20(loanToken).balanceOf(LIQUIDATOR), + 0, + "no loan repaid" + ); + assertGt( + IERC20(collToken).balanceOf(LIQUIDATOR) - liqCollBefore, + 0, + "no collateral received" + ); + + // Verify redeemer received protocol fee (underlying tokens) + uint256 redeemerCollAfter = IERC20(collToken).balanceOf( + address(redeemer) + ); + if (protocolFee > 0) { + assertGt( + redeemerCollAfter, + redeemerCollBefore, + "Redeemer should receive protocol fee" + ); + assertEq( + redeemerCollAfter - redeemerCollBefore, + protocolFee, + "Redeemer balance should match protocol fee from event" + ); + + address mTokenCollateral = _findMTokenForUnderlying(collToken); + if ( + mTokenCollateral != address(0) && + redeemer.whitelistedMarkets(mTokenCollateral) + ) { + _addReservesAndVerify(mTokenCollateral); + } else { + console2.log( + "No mToken market found or not whitelisted for collateral token", + IERC20(collToken).symbol() + ); + } + } else { + // When protocolFee is 0, redeemer should not receive any tokens + assertEq( + redeemerCollAfter, + redeemerCollBefore, + "Redeemer should not receive tokens when protocolFee is 0" + ); + } + } + + /// @notice Parse liquidation event to extract fees + function _parseLiquidationEvent() + internal + returns (uint256 protocolFee, uint256 liquidatorFee) + { + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 eventSig = keccak256( + "PriceUpdatedEarlyAndLiquidated(address,uint256,uint256,uint256,uint256)" + ); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + (, , uint256 _protocolFee, uint256 _liquidatorFee) = abi.decode( + logs[i].data, + (uint256, uint256, uint256, uint256) + ); + + protocolFee = _protocolFee; + liquidatorFee = _liquidatorFee; + } + } + } + + /// @notice Find mToken address for a given underlying token + /// @param underlyingToken The underlying token address + /// @return mToken The mToken address, or address(0) if not found + function _findMTokenForUnderlying( + address underlyingToken + ) internal view returns (address mToken) { + // Try common mToken names based on token symbol + string memory symbol = IERC20(underlyingToken).symbol(); + + // Map common symbols to mToken address keys + string memory mTokenKey = string(abi.encodePacked("MOONWELL_", symbol)); + + if (addresses.isAddressSet(mTokenKey)) { + return addresses.getAddress(mTokenKey); + } + return address(0); + } + + /// @notice Add reserves from underlying token balance and verify + /// @param mTokenCollateralAddr Address of the collateral mToken + function _addReservesAndVerify(address mTokenCollateralAddr) internal { + uint256 reservesBefore = MErc20(mTokenCollateralAddr).totalReserves(); + redeemer.addReserves(mTokenCollateralAddr); + uint256 reservesAfter = MErc20(mTokenCollateralAddr).totalReserves(); + + // Verify reserves increased after adding + assertGt( + reservesAfter, + reservesBefore, + "Reserves should increase after adding protocol fees" + ); + + // Verify redeemer no longer has underlying tokens + address underlying = MErc20(mTokenCollateralAddr).underlying(); + assertEq( + IERC20(underlying).balanceOf(address(redeemer)), + 0, + "Redeemer should have no underlying tokens after adding reserves" + ); + } + + function testUpdatePriceEarlyAndLiquidate_RevertArgsZero() public { + MarketParams memory params; + address mUSDC = addresses.getAddress("MOONWELL_USDC"); + address mWETH = addresses.getAddress("MOONWELL_WETH"); + params.loanToken = MErc20(mUSDC).underlying(); + params.collateralToken = MErc20(mWETH).underlying(); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0), 1, 1); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0xBEEF), 0, 1); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0xBEEF), 1, 0); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertInvalidPrice() public { + MarketParams memory params; + address mUSDC = addresses.getAddress("MOONWELL_USDC"); + address mWETH = addresses.getAddress("MOONWELL_WETH"); + params.loanToken = MErc20(mUSDC).underlying(); + params.collateralToken = MErc20(mWETH).underlying(); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), + int256(0), + uint256(0), + block.timestamp, + uint80(1) + ) + ); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0xBEEF), 1, 1); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertIncompleteRound() public { + MarketParams memory params; + address mUSDC = addresses.getAddress("MOONWELL_USDC"); + address mWETH = addresses.getAddress("MOONWELL_WETH"); + params.loanToken = MErc20(mUSDC).underlying(); + params.collateralToken = MErc20(mWETH).underlying(); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), + int256(3_000e8), + uint256(0), + uint256(0), + uint80(1) + ) + ); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0xBEEF), 1, 1); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertStalePrice() public { + MarketParams memory params; + address mUSDC = addresses.getAddress("MOONWELL_USDC"); + address mWETH = addresses.getAddress("MOONWELL_WETH"); + params.loanToken = MErc20(mUSDC).underlying(); + params.collateralToken = MErc20(mWETH).underlying(); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(2), + int256(3_000e8), + uint256(0), + block.timestamp, + uint80(1) + ) + ); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate(params, address(0xBEEF), 1, 1); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertFeeZeroWhenMultiplierZero() + public + { + MarketParams memory params; + address mUSDC = addresses.getAddress("MOONWELL_USDC"); + address mWETH = addresses.getAddress("MOONWELL_WETH"); + params.loanToken = MErc20(mUSDC).underlying(); + params.collateralToken = MErc20(mWETH).underlying(); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVMorphoWrapper wrapper = wrappers[i]; + vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); + wrapper.setFeeMultiplier(0); + _mockValidRound(wrapper, 10, 3_000e8); + + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + params, + address(0xBEEF), + 1 ether, + 1 + ); + } + } + + function _mockValidRound( + ChainlinkOEVMorphoWrapper wrapper, + uint80 roundId_, + int256 price_ + ) internal { + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode(roundId_, price_, uint256(0), block.timestamp, roundId_) + ); + } +} diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 7d168920b..76848a139 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.19; import "@forge-std/Test.sol"; import {IERC20Metadata as IERC20} from "@openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {MErc20} from "@protocol/MErc20.sol"; import {MToken} from "@protocol/MToken.sol"; @@ -11,24 +12,41 @@ import {MErc20Delegator} from "@protocol/MErc20Delegator.sol"; import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; import {ChainlinkOracle} from "@protocol/oracles/ChainlinkOracle.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; -import {ChainlinkFeedOEVWrapper} from "@protocol/oracles/ChainlinkFeedOEVWrapper.sol"; - -contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { - event FeeMultiplierChanged(uint8 oldFee, uint8 newFee); - event ProtocolOEVRevenueUpdated( - address indexed receiver, - uint256 revenueAdded, - uint256 roundId +import {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; +import {ChainlinkOracleConfigs} from "@proposals/ChainlinkOracleConfigs.sol"; +import {LiquidationData, Liquidations, LiquidationState} from "@test/utils/Liquidations.sol"; +import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; +import {OEVProtocolFeeRedeemer} from "@protocol/OEVProtocolFeeRedeemer.sol"; + +contract ChainlinkOEVWrapperIntegrationTest is + PostProposalCheck, + ChainlinkOracleConfigs, + Liquidations +{ + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + event PriceUpdatedEarlyAndLiquidated( + address indexed borrower, + uint256 repayAmount, + address mTokenCollateral, + address mTokenLoan, + uint256 protocolFee, + uint256 liquidatorFee ); - event MaxDecrementsChanged(uint8 oldMaxDecrements, uint8 newMaxDecrements); - event NewMaxRoundDelay(uint8 oldWindow, uint8 newWindow); - ChainlinkFeedOEVWrapper public wrapper; + // Array of wrappers to test, resolved from oracle configs + ChainlinkOEVWrapper[] public wrappers; Comptroller comptroller; MarketBase public marketBase; + OEVProtocolFeeRedeemer public redeemer; - uint256 public constant multiplier = 99; - uint256 latestRoundOnChain; + // Test actors + address internal constant BORROWER = + address(uint160(uint256(keccak256(abi.encodePacked("BORROWER"))))); + address internal constant LIQUIDATOR = + address(uint160(uint256(keccak256(abi.encodePacked("LIQUIDATOR"))))); function setUp() public override { uint256 primaryForkId = vm.envUint("PRIMARY_FORK_ID"); @@ -37,14 +55,54 @@ contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { vm.selectFork(primaryForkId); comptroller = Comptroller(addresses.getAddress("UNITROLLER")); marketBase = new MarketBase(comptroller); + // Resolve wrappers from oracle configurations for the active chain + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + for (uint256 i = 0; i < oracleConfigs.length; i++) { + string memory wrapperName = string( + abi.encodePacked(oracleConfigs[i].oracleName, "_OEV_WRAPPER") + ); + if (addresses.isAddressSet(wrapperName)) { + ChainlinkOEVWrapper wrapper = ChainlinkOEVWrapper( + payable(addresses.getAddress(wrapperName)) + ); + wrappers.push(wrapper); + + // Make wrapper persistent so it survives fork rolls + vm.makePersistent(address(wrapper)); + } + } + + ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); + vm.makePersistent(address(oracle)); - // Deploy a new wrapper for testing - wrapper = ChainlinkFeedOEVWrapper( - addresses.getAddress("CHAINLINK_ETH_USD_OEV_WRAPPER") + redeemer = OEVProtocolFeeRedeemer( + payable(addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER")) ); + vm.makePersistent(address(redeemer)); + } + + function _perWrapperActor( + string memory label, + address wrapper + ) internal pure returns (address) { + return + address( + uint160(uint256(keccak256(abi.encodePacked(label, wrapper)))) + ); + } + + function _borrower( + ChainlinkOEVWrapper wrapper + ) internal pure returns (address) { + return _perWrapperActor("BORROWER", address(wrapper)); + } - // get latest round - latestRoundOnChain = wrapper.originalFeed().latestRound(); + function _liquidator( + ChainlinkOEVWrapper wrapper + ) internal pure returns (address) { + return _perWrapperActor("LIQUIDATOR", address(wrapper)); } function _mintMToken( @@ -70,422 +128,173 @@ contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { vm.stopPrank(); } - function testCanUpdatePriceEarly() public { - vm.warp(vm.getBlockTimestamp() + 1 days); - - int256 mockPrice = 3_000e8; // chainlink oracle uses 8 decimals - - uint256 tax = (50 gwei - 25 gwei) * multiplier; // (gasPrice - baseFee) * multiplier - vm.deal(address(this), tax); - vm.txGasPrice(50 gwei); // Set gas price to 50 gwei - vm.fee(25 gwei); // Set base fee to 25 gwei - vm.expectEmit(address(wrapper)); - emit ProtocolOEVRevenueUpdated( - addresses.getAddress("MOONWELL_WETH"), - tax, - uint256(latestRoundOnChain + 1) - ); - - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint256(latestRoundOnChain + 1), - mockPrice, - 0, - block.timestamp, - uint256(latestRoundOnChain + 1) - ) - ); - - wrapper.updatePriceEarly{value: tax}(); - - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint256(latestRoundOnChain + 1), - mockPrice, - 0, - block.timestamp, - uint256(latestRoundOnChain + 1) - ) - ); - - (, int256 answer, , uint256 timestamp, ) = wrapper.latestRoundData(); - - assertEq(mockPrice, answer, "Price should be the same as answer"); - assertEq( - timestamp, - block.timestamp, - "Timestamp should be the same as block.timestamp " - ); - - // assert round id and timestamp are cached - assertEq( - wrapper.cachedRoundId(), - latestRoundOnChain + 1, - "Round id should be cached" - ); - } - function testReturnPreviousRoundIfNoOneHasPaidForCurrentRoundAndNewRoundIsWithinMaxRoundDelay() public { int256 mockPrice = 3_3333e8; // chainlink oracle uses 8 decimals + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint256(latestRoundOnChain + 1), - mockPrice, - 0, - block.timestamp, - uint256(latestRoundOnChain + 1) - ) - ); - - uint256 mockTimestamp = block.timestamp - 1; - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector, - uint80(latestRoundOnChain) - ), - abi.encode( - uint80(latestRoundOnChain), - mockPrice, - 0, - mockTimestamp, - uint80(latestRoundOnChain) - ) - ); - - (uint256 roundId, int256 answer, , uint256 timestamp, ) = wrapper - .latestRoundData(); - - assertEq(roundId, latestRoundOnChain, "Round ID should be the same"); - assertEq(mockPrice, answer, "Price should be the same as answer"); - assertEq( - timestamp, - mockTimestamp, - "Timestamp should be the same as block.timestamp" - ); - } - - function testReturnLatestRoundIfBlockTimestampIsOlderThanBlockTImestampPlusMaxRoundDelay() - public - { - int256 mockPrice = 3_000e8; // chainlink oracle uses 8 decimals - - uint256 expectedTimestamp = block.timestamp; - - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint256(latestRoundOnChain + 1), - mockPrice, - 0, - block.timestamp, - uint256(latestRoundOnChain + 1) - ) - ); - - vm.warp(block.timestamp + wrapper.maxRoundDelay()); - - (uint256 roundID, int256 answer, , uint256 timestamp, ) = wrapper - .latestRoundData(); - - assertEq( - roundID, - latestRoundOnChain + 1, - "Round ID should be the same" - ); - assertEq(mockPrice, answer, "Price should be the same as answer"); - assertEq( - timestamp, - expectedTimestamp, - "Timestamp should be the same as block.timestamp" - ); - } - - function testRevertIfInsufficientTax() public { - uint256 tax = 25 gwei * multiplier; - vm.deal(address(this), tax - 1); - - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); - vm.expectRevert("ChainlinkOEVWrapper: Insufficient tax"); - wrapper.updatePriceEarly{value: tax - 1}(); - } - - function testUpdatePriceEarlyOnLiquidationOpportunity() public { - address user = address(0x1234); - // Supply weth - MToken mToken = MToken(addresses.getAddress("MOONWELL_WETH")); - MToken mTokenBorrowed = MToken(addresses.getAddress("MOONWELL_USDC")); - - uint256 mintAmount = marketBase.getMaxSupplyAmount(mToken); - _mintMToken(user, address(mToken), mintAmount); - { - // Enter WETH and USDC markets - vm.startPrank(user); - address[] memory markets = new address[](2); - markets[0] = address(mToken); - markets[1] = address(mTokenBorrowed); - comptroller.enterMarkets(markets); - vm.stopPrank(); - } - - uint256 borrowAmount; - { - // Calculate maximum borrow amount - (, uint256 liquidity, ) = comptroller.getAccountLiquidity(user); - - // Use 80% of max liquidity to leave room for price movement - // usdc is 6 decimals, liquidity is in 18 decimals - // so we need to convert borrow amount to 6 decimals - borrowAmount = ((liquidity * 80) / 100) / 1e12; // Changed from full amount - - // Ensure sufficient borrow cap using the utility function - marketBase.ensureSufficientBorrowCap( - mTokenBorrowed, - borrowAmount, - addresses - ); - - // make sure the mToken has enough underlying to borrow - deal( - MErc20(address(mTokenBorrowed)).underlying(), - address(mTokenBorrowed), - borrowAmount - ); - - vm.warp(block.timestamp + 1 days); - vm.prank(user); - uint256 err = MErc20(address(mTokenBorrowed)).borrow(borrowAmount); - assertEq(err, 0, "Borrow failed"); - } - - { - (, int256 priceBefore, , , ) = wrapper.latestRoundData(); - int256 newPrice = (priceBefore * 70) / 100; // 30% drop - - uint256 tax = (50 gwei - 25 gwei) * - uint256(wrapper.feeMultiplier()); - vm.deal(address(this), tax); - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); + uint256 latestRoundOnChain = wrapper.priceFeed().latestRound(); vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector + wrapper.priceFeed().latestRoundData.selector ), abi.encode( uint256(latestRoundOnChain + 1), - newPrice, + mockPrice, 0, block.timestamp, uint256(latestRoundOnChain + 1) ) ); - wrapper.updatePriceEarly{value: tax}(); + + uint256 mockTimestamp = block.timestamp - 1; vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector, - uint80(latestRoundOnChain + 1) + wrapper.priceFeed().getRoundData.selector, + uint80(latestRoundOnChain) ), abi.encode( - uint80(latestRoundOnChain + 1), - newPrice, + uint80(latestRoundOnChain), + mockPrice, 0, - block.timestamp, - uint80(latestRoundOnChain + 1) + mockTimestamp, + uint80(latestRoundOnChain) ) ); + (uint256 roundId, int256 answer, , uint256 timestamp, ) = wrapper + .latestRoundData(); + + assertEq( + roundId, + latestRoundOnChain, + "Round ID should be the same" + ); + assertEq(mockPrice, answer, "Price should be the same as answer"); + assertEq( + timestamp, + mockTimestamp, + "Timestamp should be the same as block.timestamp" + ); + } + } + + function testReturnLatestRoundIfBlockTimestampIsOlderThanBlockTImestampPlusMaxRoundDelay() + public + { + int256 mockPrice = 3_000e8; // chainlink oracle uses 8 decimals + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + uint256 latestRoundOnChain = wrapper.priceFeed().latestRound(); + uint256 expectedTimestamp = block.timestamp; + vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().latestRound.selector + wrapper.priceFeed().latestRoundData.selector ), - abi.encode(uint256(latestRoundOnChain + 1)) + abi.encode( + uint256(latestRoundOnChain + 1), + mockPrice, + 0, + block.timestamp, + uint256(latestRoundOnChain + 1) + ) ); - (uint256 err, uint256 liquidity, uint256 shortfall) = comptroller - .getHypotheticalAccountLiquidity(user, address(mToken), 0, 0); - - assertEq(err, 0, "Error in hypothetical liquidity calculation"); - assertEq(liquidity, 0, "Liquidity should be 0"); - assertGt(shortfall, 0, "Position should be underwater"); - } - - // Setup liquidator - address liquidator = address(0x5678); - uint256 repayAmount = borrowAmount / 4; + vm.warp(block.timestamp + wrapper.maxRoundDelay()); - deal( - MErc20(address(mTokenBorrowed)).underlying(), - liquidator, - repayAmount - ); + (uint256 roundID, int256 answer, , uint256 timestamp, ) = wrapper + .latestRoundData(); - // Execute liquidation - vm.startPrank(liquidator); - IERC20(MErc20(address(mTokenBorrowed)).underlying()).approve( - address(mTokenBorrowed), - repayAmount - ); - assertEq( - MErc20Delegator(payable(address(mTokenBorrowed))).liquidateBorrow( - user, - repayAmount, - MErc20(address(mToken)) - ), - 0, - "Liquidation failed" - ); - vm.stopPrank(); + assertEq( + roundID, + latestRoundOnChain + 1, + "Round ID should be the same" + ); + assertEq(mockPrice, answer, "Price should be the same as answer"); + assertEq( + timestamp, + expectedTimestamp, + "Timestamp should be the same as block.timestamp" + ); + } } function testSetFeeMultiplier() public { - uint8 newMultiplier = 1; - - uint8 originalMultiplier = wrapper.feeMultiplier(); - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit FeeMultiplierChanged(originalMultiplier, newMultiplier); - wrapper.setFeeMultiplier(newMultiplier); - - assertEq( - wrapper.feeMultiplier(), - newMultiplier, - "Fee multiplier not updated" - ); + uint16 newMultiplier = 100; // 1% + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + uint16 originalMultiplier = wrapper.feeMultiplier(); + vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); + vm.expectEmit(address(wrapper)); + emit FeeMultiplierChanged(originalMultiplier, newMultiplier); + wrapper.setFeeMultiplier(newMultiplier); + assertEq( + wrapper.feeMultiplier(), + newMultiplier, + "Fee multiplier not updated" + ); + } } function testSetFeeMultiplierRevertNonOwner() public { - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setFeeMultiplier(1); - } - - function testSetMaxRoundDelay() public { - uint8 newWindow = 3; - - uint8 originalWindow = wrapper.maxRoundDelay(); - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit NewMaxRoundDelay(originalWindow, newWindow); - wrapper.setMaxRoundDelay(newWindow); - - assertEq( - wrapper.maxRoundDelay(), - newWindow, - "Max round delay not updated" - ); - } - - function testmaxRoundDelayRevertNonOwner() public { - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setMaxRoundDelay(10); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + vm.expectRevert("Ownable: caller is not the owner"); + wrapper.setFeeMultiplier(1); + } } function testGetRoundData() public { uint80 roundId = 1; int256 mockPrice = 3_000e8; uint256 mockTimestamp = block.timestamp; + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + // Mock the original feed's getRoundData response + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().getRoundData.selector, + roundId + ), + abi.encode( + roundId, + mockPrice, + uint256(0), + mockTimestamp, + roundId + ) + ); - // Mock the original feed's getRoundData response - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector, - roundId - ), - abi.encode(roundId, mockPrice, uint256(0), mockTimestamp, roundId) - ); - - ( - uint80 returnedRoundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) = wrapper.getRoundData(roundId); - - assertEq(returnedRoundId, roundId, "Round ID should be the same"); - assertEq(answer, mockPrice, "Price should be the same"); - assertEq(startedAt, 0, "StartedAt should be 0"); - assertEq( - updatedAt, - mockTimestamp, - "UpdatedAt should be the same as block.timestamp" - ); - assertEq( - answeredInRound, - roundId, - "AnsweredInRound should be the same as round ID" - ); - } - - function testFeeAmountIsAdddedToEthReserves() public { - // accrue interest - MErc20(addresses.getAddress("MOONWELL_WETH")).accrueInterest(); - uint256 wethBalanceBefore = IERC20(addresses.getAddress("WETH")) - .balanceOf(addresses.getAddress("MOONWELL_WETH")); - uint256 totalReservesBefore = MErc20( - addresses.getAddress("MOONWELL_WETH") - ).totalReserves(); - - uint256 tax = 25 gwei * multiplier; - vm.deal(address(this), tax); - - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); - - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - latestRoundOnChain + 1, - 3_000e8, - 0, - block.timestamp, - latestRoundOnChain + 1 - ) - ); - wrapper.updatePriceEarly{value: tax}(); - - uint256 totalReservesAfter = MErc20( - addresses.getAddress("MOONWELL_WETH") - ).totalReserves(); - - assertEq( - totalReservesBefore + tax, - totalReservesAfter, - "Total reserves should be increased by tax" - ); - assertEq( - wethBalanceBefore + tax, - IERC20(addresses.getAddress("WETH")).balanceOf( - addresses.getAddress("MOONWELL_WETH") - ), - "WETH balance should be increased by tax" - ); + ( + uint80 returnedRoundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = wrapper.getRoundData(roundId); + + assertEq(returnedRoundId, roundId, "Round ID should be the same"); + assertEq(answer, mockPrice, "Price should be the same"); + assertEq(startedAt, 0, "StartedAt should be 0"); + assertEq( + updatedAt, + mockTimestamp, + "UpdatedAt should be the same as block.timestamp" + ); + assertEq( + answeredInRound, + roundId, + "AnsweredInRound should be the same as round ID" + ); + } } function testAllChainlinkOraclesAreSet() public view { @@ -496,6 +305,15 @@ contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); for (uint i = 0; i < allMarkets.length; i++) { + // Skip LBTC market if configured in addresses (external Redstone requirements on some forks) + if (addresses.isAddressSet("MOONWELL_LBTC")) { + if ( + address(allMarkets[i]) == + addresses.getAddress("MOONWELL_LBTC") + ) { + continue; + } + } address underlying = MErc20(address(allMarkets[i])).underlying(); // Get token symbol @@ -512,350 +330,1420 @@ contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { } } - function testMultipleAccountHealthChecks() public { - MToken[] memory allMarkets = comptroller.getAllMarkets(); - - // Test 10 different accounts - for (uint accountId = 0; accountId < 10; accountId++) { - address account = address(uint160(0x1000 + accountId)); - - // first enter all markets - address[] memory markets = new address[](allMarkets.length); - for (uint i = 0; i < allMarkets.length; i++) { - markets[i] = address(allMarkets[i]); - } - vm.prank(account); - comptroller.enterMarkets(markets); - - // Supply different amounts of each asset - for (uint marketId = 0; marketId < allMarkets.length; marketId++) { - MToken mToken = allMarkets[marketId]; - address underlying = MErc20(address(mToken)).underlying(); - - // check max mint allowed - uint256 maxMint = marketBase.getMaxSupplyAmount(mToken); + function testLatestRoundDataRevertOnChainlinkPriceIsZero() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + uint256 timestampBefore = vm.getBlockTimestamp(); + vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); - if (maxMint == 0) { - continue; - } + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), // roundId + int256(0), // answer + uint256(0), // startedAt + uint256(timestampBefore), // updatedAt + uint80(1) // answeredInRound + ) + ); - // Mint different amounts based on account and market - uint256 amount = 1000 * - (accountId + 1) * - (marketId + 1) * - (10 ** IERC20(underlying).decimals()); + vm.expectRevert("Chainlink price cannot be lower or equal to 0"); + wrapper.latestRoundData(); + } + } - if (amount > maxMint) { - amount = maxMint; - } + function testLatestRoundDataRevertOnIncompleteRoundState() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRound.selector + ), + abi.encode(uint256(1)) + ); - _mintMToken(account, address(mToken), amount); - } + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), // roundId + int256(3_000e8), // answer + uint256(0), // startedAt + uint256(0), // updatedAt - set to 0 to simulate incomplete state + uint80(1) // answeredInRound + ) + ); - // Check account liquidity - (uint err, uint liquidity, uint shortfall) = comptroller - .getAccountLiquidity(account); - assertEq(err, 0, "Error getting account liquidity"); - assertGt(liquidity, 0, "Account should have positive liquidity"); - assertEq(shortfall, 0, "Account should have no shortfall"); + vm.expectRevert("Round is in incompleted state"); + wrapper.latestRoundData(); + } + } - // Test hypothetical liquidity for each asset - for (uint marketId = 0; marketId < allMarkets.length; marketId++) { - MToken mToken = allMarkets[marketId]; + function testLatestRoundDataRevertOnStalePriceData() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + uint256 timestampBefore = vm.getBlockTimestamp(); + vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); - uint256 mTokenBalance = mToken.balanceOf(account); - if (mTokenBalance == 0) { - continue; - } + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(2), // roundId + int256(3_000e8), // answer + uint256(0), // startedAt + timestampBefore, // updatedAt + uint80(1) // answeredInRound - less than roundId to simulate stale price + ) + ); - uint redeemAmount = mTokenBalance / 2; + vm.expectRevert("Stale price"); + wrapper.latestRoundData(); + } + } - (err, liquidity, shortfall) = comptroller - .getHypotheticalAccountLiquidity( - account, - address(mToken), - redeemAmount, - 0 - ); - assertEq(err, 0, "Error getting hypothetical liquidity"); - assertGt( - liquidity, - 0, - "Account should maintain positive liquidity after hypothetical redemption" - ); - assertEq( - shortfall, - 0, - "Account should have no shortfall after hypothetical redemption" - ); - } + function testNoUpdateEarlyReturnsPreviousRound() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRound.selector + ), + abi.encode(uint256(2)) + ); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(2), // roundId + int256(3_000e8), // answer + uint256(0), // startedAt + uint256(block.timestamp), // updatedAt + uint80(3) // answeredInRound + ) + ); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().getRoundData.selector + ), + abi.encode( + uint80(1), + int256(3_001e8), + uint256(0), + uint256(block.timestamp - 1), + uint80(2) + ) + ); + // Call latestRoundData on the wrapper + ( + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = wrapper.latestRoundData(); + + // Assert that the round data matches the previous round data + assertEq(roundId, 1, "Round ID should be the previous round"); + assertEq(price, 3_001e8, "Price should be the previous price"); + assertEq( + startedAt, + 0, + "Started at timestamp should be the previous timestamp" + ); + assertEq( + updatedAt, + block.timestamp - 1, + "Updated at timestamp should be the previous timestamp" + ); + assertEq( + answeredInRound, + 2, + "Answered in round should be the previous round" + ); + } + } - vm.stopPrank(); + function testMaxDecrementsLimit() public { + // Mock the feed to return valid data for specific rounds + uint256 latestRound = 100; + for (uint256 i = 0; i < wrappers.length; i++) { + vm.clearMockedCalls(); + + ChainlinkOEVWrapper wrapper = wrappers[i]; + + // Mock valid price data for round 100 (latest) + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(latestRound), + int256(1000), + uint256(block.timestamp), + uint256(block.timestamp), + uint80(latestRound) + ) + ); + + // Should return latest price since we can't find valid price within configured decrements when none mocked + ( + uint80 roundId, + int256 answer, + , + , + uint80 answeredInRound + ) = wrapper.latestRoundData(); + assertEq( + answer, + 1000, + "Should return latest price when valid price not found within maxDecrements" + ); + assertEq( + roundId, + uint80(latestRound), + "Should return latest round ID" + ); + assertEq( + answeredInRound, + uint80(latestRound), + "Should return latest answered round" + ); + + // Mock valid price data for round 95 + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().getRoundData.selector, + uint80(latestRound - 5) + ), + abi.encode( + uint80(latestRound - 5), + int256(950), + uint256(block.timestamp - 1 hours), + uint256(block.timestamp - 1 hours), + uint80(latestRound - 5) + ) + ); + + // Should return price from round 95 + (roundId, answer, , , answeredInRound) = wrapper.latestRoundData(); + assertEq( + answer, + 950, + "Should return price from round 95 when maxDecrements allows reaching it" + ); + assertEq( + roundId, + uint80(latestRound - 5), + "Should return round 95 ID" + ); + assertEq( + answeredInRound, + uint80(latestRound - 5), + "Should return round 95 as answered round" + ); } } - function testLatestRoundDataRevertOnChainlinkPriceIsZero() public { - uint256 timestampBefore = vm.getBlockTimestamp(); - vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); + /** updatePriceEarlyAndLiquidate */ - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint80(1), // roundId - int256(0), // answer - uint256(0), // startedAt - uint256(timestampBefore), // updatedAt - uint80(1) // answeredInRound - ) + function testUpdatePriceEarlyAndLiquidate_Succeeds() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid ); - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - wrapper.latestRoundData(); + for (uint256 i = 0; i < wrappers.length; i++) { + vm.clearMockedCalls(); + + ChainlinkOEVWrapper wrapper = wrappers[i]; + + // Get the collateral mToken + string memory mTokenKey = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKey)) continue; + + address mTokenCollateralAddr = addresses.getAddress(mTokenKey); + + // Get borrow token based on collateral type + ( + string memory borrowMTokenKey, + string memory borrowTokenSymbol + ) = _getBorrowTokenForCollateral(oracleConfigs[i].symbol); + + address mTokenBorrowAddr = addresses.getAddress(borrowMTokenKey); + + // Set up synthetic position + address borrower = _borrower(wrapper); + address liquidator = _liquidator(wrapper); + (, uint256 borrowAmount) = _setupSyntheticPosition( + mTokenCollateralAddr, + mTokenBorrowAddr, + borrower + ); + + // Crash price to make position underwater + _crashPriceForLiquidation(wrapper, borrower); + + // Create synthetic liquidation data + LiquidationData memory liquidation = LiquidationData({ + timestamp: block.timestamp, + blockNumber: block.number, + borrowedToken: borrowTokenSymbol, + collateralToken: IERC20( + addresses.getAddress(oracleConfigs[i].symbol) + ).symbol(), + borrowMTokenKey: borrowMTokenKey, + collateralMTokenKey: oracleConfigs[i].mTokenKey, + borrower: borrower, + liquidator: liquidator, + repayAmount: borrowAmount / 10, + seizedCollateralAmount: 0, // Will be determined during liquidation + liquidationSizeUSD: 0 // Not used in test + }); + + _testRealLiquidation(liquidation); + } } - function testLatestRoundDataRevertOnIncompleteRoundState() public { - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector(wrapper.originalFeed().latestRound.selector), - abi.encode(uint256(1)) + /// @notice Get appropriate borrow token based on collateral type + /// @dev Uses DAI for stablecoin collateral to avoid price correlation during liquidation tests + /// @param collateralSymbol The symbol of the collateral asset + /// @return borrowMTokenKey The key for the borrow mToken (e.g., "MOONWELL_DAI") + /// @return borrowTokenSymbol The symbol of the borrow token (e.g., "DAI") + function _getBorrowTokenForCollateral( + string memory collateralSymbol + ) + internal + pure + returns (string memory borrowMTokenKey, string memory borrowTokenSymbol) + { + if ( + keccak256(bytes(collateralSymbol)) == keccak256(bytes("USDC")) || + keccak256(bytes(collateralSymbol)) == keccak256(bytes("EURC")) + ) { + return ("MOONWELL_DAI", "DAI"); + } else { + return ("MOONWELL_USDC", "USDC"); + } + } + + /// @notice Set up synthetic position by depositing collateral and borrowing + /// @return collateralAmount The amount of collateral deposited + /// @return borrowAmount The amount borrowed + function _setupSyntheticPosition( + address mTokenCollateralAddr, + address mTokenBorrowAddr, + address borrower + ) internal returns (uint256 collateralAmount, uint256 borrowAmount) { + (collateralAmount, borrowAmount) = _calculateSyntheticAmounts( + mTokenCollateralAddr, + mTokenBorrowAddr + ); + _depositCollateral( + mTokenCollateralAddr, + mTokenBorrowAddr, + borrower, + collateralAmount ); + _borrow(mTokenBorrowAddr, borrower, borrowAmount); + } - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint80(1), // roundId - int256(3_000e8), // answer - uint256(0), // startedAt - uint256(0), // updatedAt - set to 0 to simulate incomplete state - uint80(1) // answeredInRound - ) + /// @notice Calculate collateral and borrow amounts for synthetic position + function _calculateSyntheticAmounts( + address mTokenCollateralAddr, + address mTokenBorrowAddr + ) internal view returns (uint256 collateralAmount, uint256 borrowAmount) { + // Use oracle's getUnderlyingPrice which normalizes all prices to USD + ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); + uint256 priceInUSD = oracle.getUnderlyingPrice( + MToken(mTokenCollateralAddr) ); + require(priceInUSD > 0, "invalid price"); - vm.expectRevert("Round is in incompleted state"); - wrapper.latestRoundData(); + (bool isListed, uint256 collateralFactorMantissa) = comptroller.markets( + mTokenCollateralAddr + ); + require(isListed, "market not listed"); + uint256 collateralFactorBps = (collateralFactorMantissa * 10000) / 1e18; + + uint256 borrowDecimals = IERC20(MErc20(mTokenBorrowAddr).underlying()) + .decimals(); + + collateralAmount = (10_000 * 1e18 * 1e18) / priceInUSD; + borrowAmount = + ((10_000 * collateralFactorBps * 70) / (10000 * 100)) * + (10 ** borrowDecimals); } - function testLatestRoundDataRevertOnStalePriceData() public { - uint256 timestampBefore = vm.getBlockTimestamp(); - vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); + /// @notice Deposit collateral and enter markets + function _depositCollateral( + address mTokenCollateralAddr, + address mTokenBorrowAddr, + address borrower, + uint256 collateralAmount + ) internal { + MToken mToken = MToken(mTokenCollateralAddr); + if (block.timestamp <= mToken.accrualBlockTimestamp()) { + vm.warp(mToken.accrualBlockTimestamp() + 1); + } - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint80(2), // roundId - int256(3_000e8), // answer - uint256(0), // startedAt - timestampBefore, // updatedAt - uint80(1) // answeredInRound - less than roundId to simulate stale price - ) - ); + _adjustSupplyCapIfNeeded(mTokenCollateralAddr, collateralAmount); + _mintMToken(borrower, mTokenCollateralAddr, collateralAmount); - vm.expectRevert("Stale price"); - wrapper.latestRoundData(); + address[] memory markets = new address[](2); + markets[0] = mTokenCollateralAddr; + markets[1] = mTokenBorrowAddr; + vm.prank(borrower); + comptroller.enterMarkets(markets); } - function testNoUpdateEarlyReturnsPreviousRound() public { - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector(wrapper.originalFeed().latestRound.selector), - abi.encode(uint256(2)) - ); - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint80(2), // roundId - int256(3_000e8), // answer - uint256(0), // startedAt - uint256(block.timestamp), // updatedAt - uint80(3) // answeredInRound - ) + /// @notice Adjust supply cap if needed + function _adjustSupplyCapIfNeeded( + address mTokenCollateralAddr, + uint256 collateralAmount + ) internal { + uint256 supplyCap = comptroller.supplyCaps(mTokenCollateralAddr); + if (supplyCap == 0) return; + + MToken mToken = MToken(mTokenCollateralAddr); + uint256 totalSupply = mToken.totalSupply(); + uint256 exchangeRate = mToken.exchangeRateStored(); + uint256 totalUnderlyingSupply = (totalSupply * exchangeRate) / 1e18; + + if (totalUnderlyingSupply + collateralAmount >= supplyCap) { + vm.startPrank(addresses.getAddress("TEMPORAL_GOVERNOR")); + MToken[] memory mTokens = new MToken[](1); + mTokens[0] = mToken; + uint256[] memory newCaps = new uint256[](1); + newCaps[0] = (totalUnderlyingSupply + collateralAmount) * 2; + comptroller._setMarketSupplyCaps(mTokens, newCaps); + vm.stopPrank(); + } + } + + /// @notice Borrow USDC or DAI + function _borrow( + address mTokenBorrowAddr, + address borrower, + uint256 borrowAmount + ) internal { + MToken mToken = MToken(mTokenBorrowAddr); + if (block.timestamp <= mToken.accrualBlockTimestamp()) { + vm.warp(mToken.accrualBlockTimestamp() + 1); + } + + _adjustBorrowCapIfNeeded(mTokenBorrowAddr, borrowAmount); + + vm.prank(borrower); + assertEq( + MErc20Delegator(payable(mTokenBorrowAddr)).borrow(borrowAmount), + 0, + "borrow failed" ); + } + + /// @notice Adjust borrow cap if needed + function _adjustBorrowCapIfNeeded( + address mTokenBorrowAddr, + uint256 borrowAmount + ) internal { + uint256 cap = comptroller.borrowCaps(mTokenBorrowAddr); + if (cap == 0) return; + + MToken mToken = MToken(mTokenBorrowAddr); + uint256 total = mToken.totalBorrows(); + + if (total + borrowAmount >= cap) { + vm.startPrank(addresses.getAddress("TEMPORAL_GOVERNOR")); + MToken[] memory mTokens = new MToken[](1); + mTokens[0] = mToken; + uint256[] memory newCaps = new uint256[](1); + newCaps[0] = (total + borrowAmount) * 2; + comptroller._setMarketBorrowCaps(mTokens, newCaps); + vm.stopPrank(); + } + } + + /// @notice Crash price to make position underwater (for synthetic tests) + function _crashPriceForLiquidation( + ChainlinkOEVWrapper wrapper, + address borrower + ) internal { + (, int256 price, , , ) = wrapper.latestRoundData(); + int256 crashedPrice = (price * 40) / 100; // 60% price drop + + uint80 roundId = 777; vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector + wrapper.priceFeed().latestRoundData.selector ), abi.encode( - uint80(1), - int256(3_001e8), + roundId, + crashedPrice, uint256(0), - uint256(block.timestamp - 1), - uint80(2) + block.timestamp, + roundId ) ); - // Call latestRoundData on the wrapper - ( - uint80 roundId, - int256 price, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) = wrapper.latestRoundData(); - - // Assert that the round data matches the previous round data - assertEq(roundId, 1, "Round ID should be the previous round"); - assertEq(price, 3_001e8, "Price should be the previous price"); + + // Verify position is now underwater + (uint256 err, , uint256 shortfall) = comptroller.getAccountLiquidity( + borrower + ); + require(err == 0 && shortfall > 0, "position not underwater"); + } + + /// @notice Helper function to redeem protocol fees and verify the redemption + /// @param mTokenCollateralAddr Address of the collateral mToken + function _redeemAndVerifyProtocolFees( + address mTokenCollateralAddr + ) internal { + uint256 reservesBeforeRedeem = MErc20(mTokenCollateralAddr) + .totalReserves(); + redeemer.redeemAndAddReserves(mTokenCollateralAddr); + uint256 reservesAfterRedeem = MErc20(mTokenCollateralAddr) + .totalReserves(); + + // Verify reserves increased after redemption + assertGt( + reservesAfterRedeem, + reservesBeforeRedeem, + "Reserves should increase after redeeming protocol fees" + ); + + // Verify redeemer no longer has mTokens assertEq( - startedAt, + MErc20(mTokenCollateralAddr).balanceOf(address(redeemer)), 0, - "Started at timestamp should be the previous timestamp" + "Redeemer should have no mTokens after redemption" ); - assertEq( - updatedAt, - block.timestamp - 1, - "Updated at timestamp should be the previous timestamp" + } + + function testUpdatePriceEarlyAndLiquidate_RevertZeroRepay() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid ); - assertEq( - answeredInRound, - 2, - "Answered in round should be the previous round" + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 0, + mTokenAddr, + mTokenAddr + ); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertZeroBorrower() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid ); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0), + 1, + mTokenAddr, + mTokenAddr + ); + } } - function testSetMaxDecrements() public { - uint8 newMaxDecrements = 15; - uint8 originalMaxDecrements = wrapper.maxDecrements(); + function testUpdatePriceEarlyAndLiquidate_RevertZeroMToken() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 1, + address(0), + address(0xBEEF) + ); + } + } - // Non-owner should not be able to change maxDecrements - vm.prank(address(0x1234)); - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setMaxDecrements(newMaxDecrements); + function testUpdatePriceEarlyAndLiquidate_RevertFeeZeroWhenMultiplierZero() + public + { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); + wrapper.setFeeMultiplier(0); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 1, + mTokenAddr, + mTokenAddr + ); + } + } - // Owner should be able to change maxDecrements - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit MaxDecrementsChanged(originalMaxDecrements, newMaxDecrements); - wrapper.setMaxDecrements(newMaxDecrements); + function testUpdatePriceEarlyAndLiquidate_RevertInvalidPrice() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + // answer <= 0 + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), + int256(0), + uint256(0), + block.timestamp, + uint80(1) + ) + ); + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 1, + mTokenAddr, + mTokenAddr + ); + } + } - assertEq( - wrapper.maxDecrements(), - newMaxDecrements, - "maxDecrements should be updated" + function testUpdatePriceEarlyAndLiquidate_RevertIncompleteRound() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid + ); + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + // updatedAt == 0 + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(1), + int256(3_000e8), + uint256(0), + uint256(0), + uint80(1) + ) + ); + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 1, + mTokenAddr, + mTokenAddr + ); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertStalePrice() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid ); - assertNotEq( - wrapper.maxDecrements(), - originalMaxDecrements, - "maxDecrements should be different from original" + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + string memory mTokenKeyCandidate = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKeyCandidate)) { + continue; + } + // answeredInRound < roundId + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(2), + int256(3_000e8), + uint256(0), + block.timestamp, + uint80(1) + ) + ); + address mTokenAddr = addresses.getAddress(mTokenKeyCandidate); + vm.expectRevert(); + wrapper.updatePriceEarlyAndLiquidate( + address(0xBEEF), + 1, + mTokenAddr, + mTokenAddr + ); + } + } + + function testUpdatePriceEarlyAndLiquidate_RevertLiquidationFailed() public { + // Use ETH/WETH for this test + ChainlinkOEVWrapper wrapper = ChainlinkOEVWrapper( + payable(addresses.getAddress("CHAINLINK_ETH_USD_OEV_WRAPPER")) ); + MToken mTokenCollateral = MToken(addresses.getAddress("MOONWELL_WETH")); + MToken mTokenBorrow = MToken(addresses.getAddress("MOONWELL_USDC")); + address borrower = _borrower(wrapper); + uint256 borrowAmount; + + // 1) Deposit WETH as collateral + { + uint256 accrualTsPre = mTokenCollateral.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); + } + + uint256 supplyAmount = 1 ether; + _mintMToken(borrower, address(mTokenCollateral), supplyAmount); + + address[] memory markets = new address[](2); + markets[0] = address(mTokenCollateral); + markets[1] = address(mTokenBorrow); + vm.prank(borrower); + comptroller.enterMarkets(markets); + + assertTrue( + comptroller.checkMembership(borrower, mTokenCollateral), + "not in collateral market" + ); + } + + // 2) Borrow USDC against WETH collateral (but stay healthy) + { + uint256 accrualTsPre = mTokenBorrow.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); + } + + // Borrow only 1,000 USDC (well below 80% LTV, so position stays healthy) + borrowAmount = 1_000 * 1e6; + + uint256 currentBorrowCap = comptroller.borrowCaps( + address(mTokenBorrow) + ); + uint256 totalBorrows = mTokenBorrow.totalBorrows(); + uint256 nextTotalBorrows = totalBorrows + borrowAmount; + + if (currentBorrowCap != 0 && nextTotalBorrows >= currentBorrowCap) { + vm.startPrank(addresses.getAddress("TEMPORAL_GOVERNOR")); + MToken[] memory mTokens = new MToken[](1); + mTokens[0] = mTokenBorrow; + uint256[] memory newBorrowCaps = new uint256[](1); + newBorrowCaps[0] = nextTotalBorrows * 2; + comptroller._setMarketBorrowCaps(mTokens, newBorrowCaps); + vm.stopPrank(); + } + + vm.prank(borrower); + assertEq( + MErc20Delegator(payable(address(mTokenBorrow))).borrow( + borrowAmount + ), + 0, + "borrow failed" + ); + } + + // 3) Verify position is healthy (has liquidity, no shortfall) + { + (uint256 err, uint256 liq, uint256 shortfall) = comptroller + .getAccountLiquidity(borrower); + assertEq(err, 0, "liquidity error"); + assertGt(liq, 0, "expected liquidity"); + assertEq(shortfall, 0, "should have no shortfall"); + } + + // 4) Try to liquidate a healthy position - should fail + { + address liquidator = _liquidator(wrapper); + uint256 repayAmount = borrowAmount / 10; // Try to repay 100 USDC + address borrowUnderlying = MErc20(address(mTokenBorrow)) + .underlying(); + deal(borrowUnderlying, liquidator, repayAmount); + + vm.startPrank(liquidator); + IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); + + if (block.timestamp <= mTokenBorrow.accrualBlockTimestamp()) { + vm.warp(mTokenBorrow.accrualBlockTimestamp() + 1); + } + + // Liquidation should fail because position is healthy (not underwater) + vm.expectRevert(bytes("ChainlinkOEVWrapper: liquidation failed")); + wrapper.updatePriceEarlyAndLiquidate( + borrower, + repayAmount, + address(mTokenCollateral), + address(mTokenBorrow) + ); + vm.stopPrank(); + } } - function testMaxDecrementsLimit() public { - // Mock the feed to return valid data for specific rounds - uint256 latestRound = 100; + /// @notice Simulate some real liquidations from 10/10 + function testRealLiquidations() public { + LiquidationData[] memory liquidations = getLiquidations(); - // Set maxDecrements to 3 (shouldn't reach round 95) - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - wrapper.setMaxDecrements(3); + // Skip test if no liquidation data for this chain + if (liquidations.length == 0) { + return; + } + + for (uint256 i = 0; i < liquidations.length; i++) { + _testRealLiquidation(liquidations[i]); + } + } - // Mock valid price data for round 100 (latest) + function _mockValidRound( + ChainlinkOEVWrapper wrapper, + uint80 roundId_, + int256 price_ + ) internal { vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector + wrapper.priceFeed().latestRoundData.selector ), - abi.encode( - uint80(latestRound), - int256(1000), - uint256(block.timestamp), - uint256(block.timestamp), - uint80(latestRound) - ) + abi.encode(roundId_, price_, uint256(0), block.timestamp, roundId_) ); + } - // Should return latest price since we can't find valid price within 3 decrements - (uint80 roundId, int256 answer, , , uint80 answeredInRound) = wrapper - .latestRoundData(); - assertEq( - answer, - 1000, - "Should return latest price when valid price not found within maxDecrements" + /// @notice Test liquidation using real liquidation data + function _testRealLiquidation(LiquidationData memory liquidation) internal { + ( + address mTokenCollateralAddr, + address mTokenBorrowAddr, + ChainlinkOEVWrapper wrapper + ) = _setupLiquidation(liquidation); + + bool shouldContinue = _prepareLiquidation( + liquidation, + mTokenCollateralAddr, + mTokenBorrowAddr, + wrapper ); - assertEq(roundId, uint80(latestRound), "Should return latest round ID"); - assertEq( - answeredInRound, - uint80(latestRound), - "Should return latest answered round" + if (!shouldContinue) { + console2.log("Position doesn't exist, skipping"); + return; + } + + LiquidationState memory state = _executeLiquidation( + liquidation, + wrapper, + mTokenCollateralAddr, + mTokenBorrowAddr ); - // Set maxDecrements to 6 (should reach round 95) - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - wrapper.setMaxDecrements(6); + _assertLiquidationResults(state); - // Mock valid price data for round 95 - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector, - uint80(latestRound - 5) - ), - abi.encode( - uint80(latestRound - 5), - int256(950), - uint256(block.timestamp - 1 hours), - uint256(block.timestamp - 1 hours), - uint80(latestRound - 5) - ) + // _verifyLiquidationResults( + // liquidation, + // state, + // mTokenCollateralAddr, + // mTokenBorrowAddr + // ); + } + + /// @notice Setup liquidation by getting addresses and finding wrapper + function _setupLiquidation( + LiquidationData memory liquidation + ) + internal + view + returns ( + address mTokenCollateralAddr, + address mTokenBorrowAddr, + ChainlinkOEVWrapper wrapper + ) + { + require( + addresses.isAddressSet(liquidation.collateralMTokenKey), + "Collateral mToken not found" + ); + require( + addresses.isAddressSet(liquidation.borrowMTokenKey), + "Borrow mToken not found" ); - // Should return price from round 95 - (roundId, answer, , , answeredInRound) = wrapper.latestRoundData(); - assertEq( - answer, - 950, - "Should return price from round 95 when maxDecrements allows reaching it" + mTokenCollateralAddr = addresses.getAddress( + liquidation.collateralMTokenKey + ); + mTokenBorrowAddr = addresses.getAddress(liquidation.borrowMTokenKey); + + bool found; + (wrapper, found) = _findWrapperForCollateral( + liquidation.collateralToken + ); + require(found, "Wrapper not found for collateral token"); + } + + /// @notice Prepare liquidation by warping time and validating position + /// @return shouldContinue True if liquidation should proceed, false if position doesn't exist + function _prepareLiquidation( + LiquidationData memory liquidation, + address mTokenCollateralAddr, + address mTokenBorrowAddr, + ChainlinkOEVWrapper wrapper + ) internal returns (bool shouldContinue) { + // Skip fork rolling for synthetic tests (blockNumber == current block.number) + // Real liquidations have historical block numbers + if (liquidation.blockNumber != block.number) { + vm.rollFork(liquidation.blockNumber - 1); // ensure onchain state + vm.warp(liquidation.timestamp - 1); // ensures mToken accrual timestamps + } + + address borrower = liquidation.borrower; + MToken mTokenBorrow = MToken(mTokenBorrowAddr); + MToken mTokenCollateral = MToken(mTokenCollateralAddr); + + // NOTE: this seems to be needed to get past some "delta" errors + // Explicitly accrue interest at the current timestamp to ensure accrual timestamps are set correctly + mTokenBorrow.accrueInterest(); + mTokenCollateral.accrueInterest(); + + uint256 borrowBalance = mTokenBorrow.borrowBalanceStored(borrower); + if (borrowBalance == 0) { + return false; // Position doesn't exist, skip + } + + // Skip price mocking for synthetic tests (price already crashed in _crashPriceForLiquidation) + // Only mock price for real liquidations + if (liquidation.blockNumber != block.number) { + // Mock collateral price down to make position underwater + AggregatorV3Interface priceFeed = wrapper.priceFeed(); + (uint80 feedRoundId, int256 price, , , ) = priceFeed + .latestRoundData(); + int256 crashedPrice = (price * 75) / 100; // 25% price drop + uint80 latestRoundId = feedRoundId; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + latestRoundId, + crashedPrice, + uint256(0), + block.timestamp, + latestRoundId + ) + ); + + // Mock getRoundData for previous rounds + _mockPreviousRounds( + wrapper, + latestRoundId, + crashedPrice, + block.timestamp + ); + } + + // Verify position is now underwater after price crash + (uint256 err, , uint256 shortfall) = comptroller.getAccountLiquidity( + borrower + ); + require(err == 0 && shortfall > 0, "Position not underwater"); + return true; + } + + /// @notice Mock previous rounds for price feed to handle wrapper's round search logic + function _mockPreviousRounds( + ChainlinkOEVWrapper wrapper, + uint80 latestRoundId, + int256 crashedPrice, + uint256 timestamp + ) internal { + uint256 maxDecrements = wrapper.maxDecrements(); + AggregatorV3Interface priceFeed = wrapper.priceFeed(); + + uint80 startRound = latestRoundId > maxDecrements + ? uint80(latestRoundId - maxDecrements) + : 1; + + for (uint80 i = startRound; i < latestRoundId; i++) { + uint256 roundTimestamp = timestamp - + uint256(latestRoundId - i) * + 12; + vm.mockCall( + address(priceFeed), + abi.encodeWithSelector(priceFeed.getRoundData.selector, i), + abi.encode(i, crashedPrice, uint256(0), roundTimestamp, i) + ); + } + } + + /// @notice Execute the liquidation + function _executeLiquidation( + LiquidationData memory liquidation, + ChainlinkOEVWrapper wrapper, + address mTokenCollateralAddr, + address mTokenBorrowAddr + ) internal returns (LiquidationState memory state) { + address borrower = liquidation.borrower; + address liquidator = liquidation.liquidator; + uint256 repayAmount = liquidation.repayAmount; + + address borrowUnderlying = MErc20(mTokenBorrowAddr).underlying(); + deal(borrowUnderlying, liquidator, repayAmount * 2); + vm.warp(liquidation.timestamp); + + // WIP; mocking onchain state for historical liquidations + // // For historical liquidations, set up the necessary state + // // At block N-1, they may not have had the position yet or been underwater + // if (liquidation.blockNumber != block.number) { + // // Give borrower enough collateral (accountTokens is at slot 14) + // bytes32 collateralSlot = keccak256(abi.encode(borrower, uint256(14))); + // vm.store(mTokenCollateralAddr, collateralSlot, bytes32(uint256(1e25))); // 10M mTokens + + // // Mock liquidateBorrowAllowed to always return success + // vm.mockCall( + // address(comptroller), + // abi.encodeWithSignature( + // "liquidateBorrowAllowed(address,address,address,address,uint256)" + // ), + // abi.encode(uint256(0)) // Return 0 = success + // ); + // } + + MToken mTokenBorrow = MToken(mTokenBorrowAddr); + MToken mTokenCollateral = MToken(mTokenCollateralAddr); + + // Get balances before liquidation + state.borrowerBorrowBefore = mTokenBorrow.borrowBalanceStored(borrower); + state.borrowerCollateralBefore = mTokenCollateral.balanceOf(borrower); + state.reservesBefore = mTokenCollateral.totalReserves(); + + uint256 liquidatorMTokenBefore = mTokenCollateral.balanceOf(liquidator); + uint256 redeemerMTokenBefore = mTokenCollateral.balanceOf( + address(redeemer) + ); + + // Execute liquidation + vm.startPrank(liquidator); + IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); + + vm.recordLogs(); + wrapper.updatePriceEarlyAndLiquidate( + borrower, + repayAmount, + mTokenCollateralAddr, + mTokenBorrowAddr + ); + vm.stopPrank(); + + ( + state.protocolFee, + state.liquidatorFeeReceived + ) = _parseLiquidationEvent(); + + // Verify liquidator received mTokens + uint256 liquidatorMTokenAfter = mTokenCollateral.balanceOf(liquidator); + assertGt( + liquidatorMTokenAfter, + liquidatorMTokenBefore, + "Liquidator should receive mTokens" ); - assertEq(roundId, uint80(latestRound - 5), "Should return round 95 ID"); assertEq( - answeredInRound, - uint80(latestRound - 5), - "Should return round 95 as answered round" + liquidatorMTokenAfter - liquidatorMTokenBefore, + state.liquidatorFeeReceived, + "Liquidator mToken balance should match liquidator fee from event" + ); + + // Verify redeemer received mTokens (protocol fee) - only if protocolFee > 0 + uint256 redeemerMTokenAfter = mTokenCollateral.balanceOf( + address(redeemer) ); + if (state.protocolFee > 0) { + assertGt( + redeemerMTokenAfter, + redeemerMTokenBefore, + "Redeemer should receive protocol fee mTokens" + ); + assertEq( + redeemerMTokenAfter - redeemerMTokenBefore, + state.protocolFee, + "Redeemer mToken balance should match protocol fee from event" + ); + + // Get reserves before redemption + uint256 reservesBeforeRedemption = mTokenCollateral.totalReserves(); + + // Redeem protocol fees and add to reserves + _redeemAndVerifyProtocolFees(mTokenCollateralAddr); + + // Get reserves after redemption + uint256 reservesAfterRedemption = mTokenCollateral.totalReserves(); + + // Calculate how much underlying was added to reserves + state.protocolFeeRedeemed = + reservesAfterRedemption - + reservesBeforeRedemption; + } else { + // When protocolFee is 0, redeemer should not receive any mTokens + assertEq( + redeemerMTokenAfter, + redeemerMTokenBefore, + "Redeemer should not receive mTokens when protocolFee is 0" + ); + state.protocolFeeRedeemed = 0; + } + + // Get balances after liquidation and redemption + state.borrowerBorrowAfter = mTokenBorrow.borrowBalanceStored(borrower); + state.borrowerCollateralAfter = mTokenCollateral.balanceOf(borrower); + state.reservesAfter = mTokenCollateral.totalReserves(); } - function testUpdatePriceEarlyFailsOnAddReserves() public { - // Mock _addReserves to return error code 1 (failure) - vm.mockCall( - address(addresses.getAddress("MOONWELL_WETH")), - abi.encodeWithSelector(MErc20._addReserves.selector), - abi.encode(uint256(1)) + /// @notice Parse liquidation event to extract fees + function _parseLiquidationEvent() + internal + returns (uint256 protocolFee, uint256 liquidatorFee) + { + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 eventSig = keccak256( + "PriceUpdatedEarlyAndLiquidated(address,uint256,address,address,uint256,uint256)" ); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == eventSig) { + (, , , uint256 _protocolFee, uint256 _liquidatorFee) = abi + .decode( + logs[i].data, + (uint256, address, address, uint256, uint256) + ); - // Set gas price higher than base fee to avoid underflow - vm.fee(1 gwei); - vm.txGasPrice(2 gwei); + protocolFee = _protocolFee; + liquidatorFee = _liquidatorFee; + } + } + } - // Calculate required payment - uint256 payment = (tx.gasprice - block.basefee) * - uint256(wrapper.feeMultiplier()); + /// @notice Struct to hold price and decimal info + struct PriceInfo { + uint256 collateralPriceUSD; + uint256 borrowPriceUSD; + uint8 collateralDecimals; + uint8 borrowDecimals; + } - vm.deal(address(this), payment); + /// @notice Verify liquidation results and log + /// @dev only needed to verify USD values on real liquidations + function _verifyLiquidationResults( + LiquidationData memory liquidation, + LiquidationState memory state, + address mTokenCollateralAddr, + address mTokenBorrowAddr + ) internal view { + PriceInfo memory priceInfo = _getPriceInfo( + mTokenCollateralAddr, + mTokenBorrowAddr + ); + USDValues memory usdValues = _calculateUSDValues( + liquidation, + state, + priceInfo, + mTokenCollateralAddr + ); - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - latestRoundOnChain + 1, - 300e8, - 0, - block.timestamp, - latestRoundOnChain + 1 - ) + _logLiquidationResults( + liquidation, + state, + usdValues, + mTokenCollateralAddr + ); + _assertLiquidationResults(state); + } + + /// @notice Get price and decimal information + function _getPriceInfo( + address mTokenCollateralAddr, + address mTokenBorrowAddr + ) internal view returns (PriceInfo memory) { + ChainlinkOracle chainlinkOracle = ChainlinkOracle( + address(comptroller.oracle()) + ); + + uint256 collateralPriceUSD = chainlinkOracle.getUnderlyingPrice( + MToken(mTokenCollateralAddr) + ); + uint256 borrowPriceUSD = chainlinkOracle.getUnderlyingPrice( + MToken(mTokenBorrowAddr) + ); + + address collateralUnderlying = MErc20(mTokenCollateralAddr) + .underlying(); + address borrowUnderlying = MErc20(mTokenBorrowAddr).underlying(); + uint8 collateralDecimals = IERC20(collateralUnderlying).decimals(); + uint8 borrowDecimals = IERC20(borrowUnderlying).decimals(); + + return + PriceInfo({ + collateralPriceUSD: collateralPriceUSD, + borrowPriceUSD: borrowPriceUSD, + collateralDecimals: collateralDecimals, + borrowDecimals: borrowDecimals + }); + } + + /// @notice Struct to hold USD values + struct USDValues { + uint256 protocolFeeUSD; + uint256 liquidatorFeeUSD; + uint256 repayAmountUSD; + uint256 protocolFeeUnderlying; + uint256 liquidatorFeeUnderlying; + uint256 protocolFeeRedeemedUSD; + } + + /// @notice Calculate USD values from token amounts + /// @dev Protocol and liquidator fees are in mToken units, need to convert to underlying using exchange rate + /// @dev getUnderlyingPrice returns prices scaled by 1e18 and already adjusted for token decimals + /// So: USD = (underlyingAmount * price) / 1e18 + function _calculateUSDValues( + LiquidationData memory liquidation, + LiquidationState memory state, + PriceInfo memory priceInfo, + address mTokenCollateralAddr + ) internal view returns (USDValues memory) { + uint256 repayAmount = liquidation.repayAmount; + + // Get exchange rate to convert mToken amounts to underlying amounts + uint256 exchangeRate = MToken(mTokenCollateralAddr) + .exchangeRateStored(); + + // Convert mToken amounts to underlying amounts + // exchangeRate has 18 decimals, so: underlying = (mTokenAmount * exchangeRate) / 1e18 + uint256 protocolFeeUnderlying = (state.protocolFee * exchangeRate) / + 1e18; + uint256 liquidatorFeeUnderlying = (state.liquidatorFeeReceived * + exchangeRate) / 1e18; + + // Calculate USD values from underlying amounts + uint256 protocolFeeUSD = (protocolFeeUnderlying * + priceInfo.collateralPriceUSD) / 1e18; + uint256 liquidatorFeeUSD = (liquidatorFeeUnderlying * + priceInfo.collateralPriceUSD) / 1e18; + uint256 repayAmountUSD = (repayAmount * priceInfo.borrowPriceUSD) / + 1e18; + + // Calculate USD value of redeemed protocol fee (already in underlying units) + uint256 protocolFeeRedeemedUSD = (state.protocolFeeRedeemed * + priceInfo.collateralPriceUSD) / 1e18; + + return + USDValues({ + protocolFeeUSD: protocolFeeUSD, + liquidatorFeeUSD: liquidatorFeeUSD, + repayAmountUSD: repayAmountUSD, + protocolFeeUnderlying: protocolFeeUnderlying, + liquidatorFeeUnderlying: liquidatorFeeUnderlying, + protocolFeeRedeemedUSD: protocolFeeRedeemedUSD + }); + } + + /// @notice Log liquidation results + function _logLiquidationResults( + LiquidationData memory liquidation, + LiquidationState memory state, + USDValues memory usdValues, + address mTokenCollateralAddr + ) internal view { + console2.log("\n=== Liquidation Results ===\n"); + console2.log("Borrower:", liquidation.borrower); + console2.log("Liquidator:", liquidation.liquidator); + console2.log("Collateral Token:", liquidation.collateralToken); + console2.log("Borrow Token:", liquidation.borrowedToken); + console2.log("\n--- Repayment ---"); + console2.log("Repay Amount:", liquidation.repayAmount); + console2.log("Repay Amount USD:", usdValues.repayAmountUSD); + console2.log("\n--- Protocol Fee (OEV capture) ---"); + console2.log("Protocol Fee (mTokens):", state.protocolFee); + console2.log( + "Protocol Fee (underlying):", + usdValues.protocolFeeUnderlying + ); + console2.log("Protocol Fee USD:", usdValues.protocolFeeUSD); + if (state.protocolFee > 0) { + console2.log( + "Protocol Fee Redeemed (underlying added to reserves):", + state.protocolFeeRedeemed + ); + console2.log( + "Protocol Fee Redeemed USD:", + usdValues.protocolFeeRedeemedUSD + ); + } + console2.log("\n--- Liquidator Fee ---"); + console2.log("Liquidator Fee (mTokens):", state.liquidatorFeeReceived); + console2.log( + "Liquidator Fee (underlying):", + usdValues.liquidatorFeeUnderlying ); - vm.expectRevert("ChainlinkOEVWrapper: Failed to add reserves"); - wrapper.updatePriceEarly{value: payment}(); + console2.log("Liquidator Fee USD:", usdValues.liquidatorFeeUSD); + console2.log("\n--- Collateral Seized (mTokens) ---"); + uint256 totalSeized = state.borrowerCollateralBefore - + state.borrowerCollateralAfter; + uint256 burnedMTokens = totalSeized - + state.liquidatorFeeReceived - + state.protocolFee; + console2.log("Total Seized from Borrower:", totalSeized); + console2.log("Distributed to Liquidator:", state.liquidatorFeeReceived); + console2.log( + "Distributed to Protocol (OEV Redeemer):", + state.protocolFee + ); + console2.log("Burned (protocol seize share):", burnedMTokens); + + // Calculate expected underlying from burned mTokens + uint256 exchangeRate = MToken(mTokenCollateralAddr) + .exchangeRateStored(); + uint256 burnedUnderlyingExpected = (burnedMTokens * exchangeRate) / + 1e18; + console2.log( + "Burned mTokens -> underlying (expected):", + burnedUnderlyingExpected + ); + + console2.log("\n--- Borrower Position ---"); + console2.log("Borrower Borrow Before:", state.borrowerBorrowBefore); + console2.log("Borrower Borrow After:", state.borrowerBorrowAfter); + console2.log( + "Borrower Collateral Before (mTokens):", + state.borrowerCollateralBefore + ); + console2.log( + "Borrower Collateral After (mTokens):", + state.borrowerCollateralAfter + ); + console2.log("\n--- Reserves (underlying tokens) ---"); + console2.log("Reserves Before:", state.reservesBefore); + console2.log("Reserves After:", state.reservesAfter); + console2.log( + "Reserves Increase:", + state.reservesAfter - state.reservesBefore + ); + if (state.protocolFee > 0) { + uint256 reservesFromBurn = (state.reservesAfter - + state.reservesBefore) - state.protocolFeeRedeemed; + console2.log( + " - From protocol seize share (burned mTokens):", + reservesFromBurn + ); + console2.log( + " - From OEV capture (redeemed mTokens):", + state.protocolFeeRedeemed + ); + } else { + console2.log(" - All from protocol seize share (burned mTokens)"); + } + console2.log("\n--- Exchange Rate ---"); + console2.log("Exchange Rate:", exchangeRate); + console2.log("\n==============================================\n"); + } + + /// @notice Assert liquidation results + function _assertLiquidationResults( + LiquidationState memory state + ) internal pure { + assertLt( + state.borrowerBorrowAfter, + state.borrowerBorrowBefore, + "Borrow not reduced" + ); + assertLt( + state.borrowerCollateralAfter, + state.borrowerCollateralBefore, + "Collateral not seized" + ); + assertGt( + state.liquidatorFeeReceived, + 0, + "Liquidator fee should be > 0" + ); + // Protocol fee can be 0 when collateral value <= repayment value + // Reserves only increase if protocolFee > 0 (after redemption) + if (state.protocolFee > 0) { + assertGt( + state.reservesAfter, + state.reservesBefore, + "Reserves should increase when protocolFee > 0" + ); + } + } + + /// @notice Find the wrapper for a given collateral token symbol + function _findWrapperForCollateral( + string memory collateralSymbol + ) internal view returns (ChainlinkOEVWrapper wrapper, bool found) { + ChainlinkOracle chainlinkOracle = ChainlinkOracle( + address(comptroller.oracle()) + ); + for (uint256 i = 0; i < wrappers.length; i++) { + try chainlinkOracle.getFeed(collateralSymbol) returns ( + AggregatorV3Interface feed + ) { + if (address(feed) == address(wrappers[i])) { + return (wrappers[i], true); + } + } catch { + continue; + } + } + return (ChainlinkOEVWrapper(payable(address(0))), false); } } diff --git a/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol b/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol deleted file mode 100644 index c4b9c0a09..000000000 --- a/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol +++ /dev/null @@ -1,428 +0,0 @@ -pragma solidity 0.8.19; - -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {console} from "@forge-std/console.sol"; - -import {ChainlinkOracleProxy} from "@protocol/oracles/ChainlinkOracleProxy.sol"; -import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; -import {MockChainlinkOracle} from "@test/mock/MockChainlinkOracle.sol"; -import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOracleWithoutLatestRound.sol"; -import {DeployChainlinkOracleProxy} from "@script/DeployChainlinkOracleProxy.s.sol"; -import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; -import {ChainIds, BASE_FORK_ID} from "@utils/ChainIds.sol"; - -contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { - using ChainIds for uint256; - - ChainlinkOracleProxy public proxy; - AggregatorV3Interface public originalFeed; - DeployChainlinkOracleProxy public deployer; - - function setUp() public override { - uint256 primaryForkId = vm.envUint("PRIMARY_FORK_ID"); - - super.setUp(); - vm.selectFork(primaryForkId); - - // revertt timestamp back - vm.warp(proposalStartTime); - - deployer = new DeployChainlinkOracleProxy(); - - originalFeed = AggregatorV3Interface( - addresses.getAddress("CHAINLINK_WELL_USD") - ); - - ( - TransparentUpgradeableProxy proxyContract, - ChainlinkOracleProxy implementation - ) = deployer.deploy(addresses); - proxy = ChainlinkOracleProxy(address(proxyContract)); - - // Validate deployment - deployer.validate(addresses, proxyContract, implementation); - } - - function testProxyReturnsEqualDecimals() public view { - uint8 proxyDecimals = proxy.decimals(); - uint8 originalDecimals = originalFeed.decimals(); - - assertEq( - proxyDecimals, - originalDecimals, - "Proxy decimals should equal original feed decimals" - ); - } - - function testProxyReturnsEqualDescription() public view { - string memory proxyDescription = proxy.description(); - string memory originalDescription = originalFeed.description(); - - assertEq( - proxyDescription, - originalDescription, - "Proxy description should equal original feed description" - ); - } - - function testProxyReturnsEqualVersion() public view { - uint256 proxyVersion = proxy.version(); - uint256 originalVersion = originalFeed.version(); - - assertEq( - proxyVersion, - originalVersion, - "Proxy version should equal original feed version" - ); - } - - function testProxyReturnsEqualLatestRoundData() public view { - ( - uint80 proxyRoundId, - int256 proxyAnswer, - uint256 proxyStartedAt, - uint256 proxyUpdatedAt, - uint80 proxyAnsweredInRound - ) = proxy.latestRoundData(); - - ( - uint80 originalRoundId, - int256 originalAnswer, - uint256 originalStartedAt, - uint256 originalUpdatedAt, - uint80 originalAnsweredInRound - ) = originalFeed.latestRoundData(); - - assertEq( - proxyRoundId, - originalRoundId, - "Proxy roundId should equal original feed roundId" - ); - assertEq( - proxyAnswer, - originalAnswer, - "Proxy answer should equal original feed answer" - ); - assertEq( - proxyStartedAt, - originalStartedAt, - "Proxy startedAt should equal original feed startedAt" - ); - assertEq( - proxyUpdatedAt, - originalUpdatedAt, - "Proxy updatedAt should equal original feed updatedAt" - ); - assertEq( - proxyAnsweredInRound, - originalAnsweredInRound, - "Proxy answeredInRound should equal original feed answeredInRound" - ); - } - - function testProxyReturnsEqualLatestRound() public view { - uint256 proxyLatestRound = proxy.latestRound(); - uint256 originalLatestRound = originalFeed.latestRound(); - - assertEq( - proxyLatestRound, - originalLatestRound, - "Proxy latestRound should equal original feed latestRound" - ); - } - - function testProxyReturnsEqualGetRoundData() public view { - uint80 roundId = uint80(originalFeed.latestRound()); - - ( - uint80 proxyRoundId, - int256 proxyAnswer, - uint256 proxyStartedAt, - uint256 proxyUpdatedAt, - uint80 proxyAnsweredInRound - ) = proxy.getRoundData(roundId); - - ( - uint80 originalRoundIdReturned, - int256 originalAnswer, - uint256 originalStartedAt, - uint256 originalUpdatedAt, - uint80 originalAnsweredInRound - ) = originalFeed.getRoundData(roundId); - - assertEq( - proxyRoundId, - originalRoundIdReturned, - "Proxy getRoundData roundId should equal original feed roundId" - ); - assertEq( - proxyAnswer, - originalAnswer, - "Proxy getRoundData answer should equal original feed answer" - ); - assertEq( - proxyStartedAt, - originalStartedAt, - "Proxy getRoundData startedAt should equal original feed startedAt" - ); - assertEq( - proxyUpdatedAt, - originalUpdatedAt, - "Proxy getRoundData updatedAt should equal original feed updatedAt" - ); - assertEq( - proxyAnsweredInRound, - originalAnsweredInRound, - "Proxy getRoundData answeredInRound should equal original feed answeredInRound" - ); - } - - function testProxyPriceFeedAddress() public view { - address proxyFeedAddress = address(proxy.priceFeed()); - address originalFeedAddress = addresses.getAddress( - "CHAINLINK_WELL_USD" - ); - - assertEq( - proxyFeedAddress, - originalFeedAddress, - "Proxy should point to correct price feed" - ); - } - - function testProxyOwnership() public view { - address proxyOwner = proxy.owner(); - - assertEq( - proxyOwner, - addresses.getAddress("MRD_PROXY_ADMIN"), - "Proxy owner should be MRD_PROXY_ADMIN" - ); - } - - function testAnswerIsPositive() public view { - (, int256 answer, , , ) = proxy.latestRoundData(); - - assertTrue(answer > 0, "Price should be positive"); - } - - function testUpdatedAtIsRecent() public view { - (, , , uint256 updatedAt, ) = proxy.latestRoundData(); - - assertTrue(updatedAt > 0, "UpdatedAt should be set"); - assertTrue( - block.timestamp - updatedAt < 86400, - "Price should be updated within 24 hours" - ); - } - - function testLatestRoundDataRevertsOnZeroPrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(0, 8); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - testProxy.latestRoundData(); - } - - function testLatestRoundDataRevertsOnNegativePrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(-1, 8); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - testProxy.latestRoundData(); - } - - function testLatestRoundDataRevertsOnZeroUpdatedAt() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - mockFeed.set(1, 100e8, 1, 0, 1); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - vm.expectRevert("Round is in incompleted state"); - testProxy.latestRoundData(); - } - - function testLatestRoundDataRevertsOnStalePrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - mockFeed.set(5, 100e8, 1, 1, 4); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - vm.expectRevert("Stale price"); - testProxy.latestRoundData(); - } - - function testGetRoundDataRevertsOnZeroPrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - mockFeed.set(5, 0, 1, 1, 5); - - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - testProxy.getRoundData(5); - } - - function testGetRoundDataRevertsOnZeroUpdatedAt() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - mockFeed.set(5, 100e8, 1, 0, 5); - - vm.expectRevert("Round is in incompleted state"); - testProxy.getRoundData(5); - } - - function testGetRoundDataRevertsOnStalePrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - mockFeed.set(5, 100e8, 1, 1, 4); - - vm.expectRevert("Stale price"); - testProxy.getRoundData(5); - } - - function testLatestRoundFallbackWhenNotSupported() public { - // Create a mock feed that doesn't support latestRound() - MockChainlinkOracleWithoutLatestRound mockFeed = new MockChainlinkOracleWithoutLatestRound( - 100e8, - 8 - ); - mockFeed.set(12345, 100e8, 1, 1, 12345); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - // Call latestRound() - should fall back to getting roundId from latestRoundData() - uint256 round = testProxy.latestRound(); - - // Verify it returns the correct roundId from latestRoundData - assertEq(round, 12345, "Should return roundId from fallback"); - } - - function testLatestRoundReturnsDirectlyWhenSupported() public { - // Create a normal mock feed that supports latestRound() - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - mockFeed.set(99999, 100e8, 1, 1, 99999); - - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - // Call latestRound() - should use the direct call - uint256 round = testProxy.latestRound(); - - // Verify it returns the correct roundId - assertEq(round, 99999, "Should return roundId from direct call"); - } -} diff --git a/test/unit/ChainlinkOEVWrapperUnit.t.sol b/test/unit/ChainlinkOEVWrapperUnit.t.sol new file mode 100644 index 000000000..65ff50c56 --- /dev/null +++ b/test/unit/ChainlinkOEVWrapperUnit.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {Test} from "@forge-std/Test.sol"; +import {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; +import {MockChainlinkOracle} from "@test/mock/MockChainlinkOracle.sol"; +import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOracleWithoutLatestRound.sol"; + +contract ChainlinkOEVWrapperUnitTest is Test { + address public owner = address(0x1); + address public chainlinkOracle = address(0x4); + address public feeRecipient = address(0x5); + uint16 public defaultFeeBps = 100; // 1% + uint256 public defaultMaxRoundDelay = 300; // 5 minutes + uint256 public defaultMaxDecrements = 5; + + // Events mirrored for expectEmit + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + + event MaxRoundDelayChanged( + uint256 oldMaxRoundDelay, + uint256 newMaxRoundDelay + ); + event MaxDecrementsChanged( + uint256 oldMaxDecrements, + uint256 newMaxDecrements + ); + + function _deploy( + address feed + ) internal returns (ChainlinkOEVWrapper wrapper) { + wrapper = new ChainlinkOEVWrapper( + feed, + owner, + chainlinkOracle, + feeRecipient, + defaultFeeBps, + defaultMaxRoundDelay, + defaultMaxDecrements + ); + } + + function testLatestRoundFallbackWhenNotSupported() public { + // Create a mock feed that doesn't support latestRound() + MockChainlinkOracleWithoutLatestRound mockFeed = new MockChainlinkOracleWithoutLatestRound( + 100e8, + 8 + ); + mockFeed.set(12345, 100e8, 1, 1, 12345); + + // Constructor should revert because it calls latestRound() + vm.expectRevert(bytes("latestRound not supported")); + new ChainlinkOEVWrapper( + address(mockFeed), + owner, + chainlinkOracle, + feeRecipient, + defaultFeeBps, + defaultMaxRoundDelay, + defaultMaxDecrements + ); + } + + function testLatestRoundReturnsDirectlyWhenSupported() public { + // Create a normal mock feed that supports latestRound() + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + mockFeed.set(99999, 100e8, 1, 1, 99999); + + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + // Call latestRound() - should use the direct call + uint256 round = wrapper.latestRound(); + + // Verify it returns the correct roundId + assertEq(round, 99999, "Should return roundId from direct call"); + } + + function testLatestRoundMatchesLatestRoundDataRoundId() public { + // When supported, latestRound should match the roundId from latestRoundData + MockChainlinkOracle mockFeed = new MockChainlinkOracle(150e8, 8); + mockFeed.set(54321, 150e8, 100, 200, 54321); + + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + // Get roundId from latestRoundData + (uint80 roundId, , , , ) = wrapper.latestRoundData(); + + // Get round from latestRound + uint256 round = wrapper.latestRound(); + + // They should match + assertEq( + round, + uint256(roundId), + "latestRound should match latestRoundData roundId" + ); + } + + function testSetFeeMultiplierUpdatesAndEmits() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + uint16 newFee = 250; // 2.5% + vm.prank(owner); + vm.expectEmit(false, false, false, true, address(wrapper)); + emit FeeMultiplierChanged(defaultFeeBps, newFee); + wrapper.setFeeMultiplier(newFee); + + assertEq(wrapper.feeMultiplier(), newFee, "feeMultiplier not updated"); + } + + function testSetFeeMultiplierAboveMaxReverts() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + uint16 overMax = wrapper.MAX_BPS() + 1; + vm.prank(owner); + vm.expectRevert( + bytes( + "ChainlinkOEVWrapper: fee multiplier cannot be greater than MAX_BPS" + ) + ); + wrapper.setFeeMultiplier(overMax); + } + + function testSetFeeMultiplierOnlyOwner() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + vm.prank(address(0xDEAD)); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + wrapper.setFeeMultiplier(200); + } + + function testSetMaxRoundDelayUpdatesAndEmits() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + uint256 newDelay = 600; // 10 minutes + vm.prank(owner); + vm.expectEmit(false, false, false, true, address(wrapper)); + emit MaxRoundDelayChanged(defaultMaxRoundDelay, newDelay); + wrapper.setMaxRoundDelay(newDelay); + + assertEq( + wrapper.maxRoundDelay(), + newDelay, + "maxRoundDelay not updated" + ); + } + + function testSetMaxRoundDelayZeroReverts() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + vm.prank(owner); + vm.expectRevert( + bytes("ChainlinkOEVWrapper: max round delay cannot be zero") + ); + wrapper.setMaxRoundDelay(0); + } + + function testSetMaxRoundDelayOnlyOwner() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + vm.prank(address(0xDEAD)); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + wrapper.setMaxRoundDelay(600); + } + + function testSetMaxDecrementsUpdatesAndEmits() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + uint256 newDec = 10; + vm.prank(owner); + vm.expectEmit(false, false, false, true, address(wrapper)); + emit MaxDecrementsChanged(defaultMaxDecrements, newDec); + wrapper.setMaxDecrements(newDec); + + assertEq(wrapper.maxDecrements(), newDec, "maxDecrements not updated"); + } + + function testSetMaxDecrementsZeroReverts() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + vm.prank(owner); + vm.expectRevert( + bytes("ChainlinkOEVWrapper: max decrements cannot be zero") + ); + wrapper.setMaxDecrements(0); + } + + function testSetMaxDecrementsOnlyOwner() public { + MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); + ChainlinkOEVWrapper wrapper = _deploy(address(mockFeed)); + + vm.prank(address(0xDEAD)); + vm.expectRevert(bytes("Ownable: caller is not the owner")); + wrapper.setMaxDecrements(10); + } +} diff --git a/test/unit/ChainlinkOracleProxyUnit.t.sol b/test/unit/ChainlinkOracleProxyUnit.t.sol deleted file mode 100644 index 2d769cf41..000000000 --- a/test/unit/ChainlinkOracleProxyUnit.t.sol +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity 0.8.19; - -import {Test} from "@forge-std/Test.sol"; -import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import {ChainlinkOracleProxy} from "@protocol/oracles/ChainlinkOracleProxy.sol"; -import {MockChainlinkOracle} from "@test/mock/MockChainlinkOracle.sol"; -import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOracleWithoutLatestRound.sol"; - -contract ChainlinkOracleProxyUnitTest is Test { - address public owner = address(0x1); - address public proxyAdmin = address(0x2); - - function testLatestRoundFallbackWhenNotSupported() public { - // Create a mock feed that doesn't support latestRound() - MockChainlinkOracleWithoutLatestRound mockFeed = new MockChainlinkOracleWithoutLatestRound( - 100e8, - 8 - ); - mockFeed.set(12345, 100e8, 1, 1, 12345); - - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - // Call latestRound() - should fall back to getting roundId from latestRoundData() - uint256 round = proxy.latestRound(); - - // Verify it returns the correct roundId from latestRoundData - assertEq(round, 12345, "Should return roundId from fallback"); - } - - function testLatestRoundReturnsDirectlyWhenSupported() public { - // Create a normal mock feed that supports latestRound() - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - mockFeed.set(99999, 100e8, 1, 1, 99999); - - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - // Call latestRound() - should use the direct call - uint256 round = proxy.latestRound(); - - // Verify it returns the correct roundId - assertEq(round, 99999, "Should return roundId from direct call"); - } - - function testLatestRoundMatchesLatestRoundDataRoundId() public { - // Test that when fallback is used, it matches the roundId from latestRoundData - MockChainlinkOracleWithoutLatestRound mockFeed = new MockChainlinkOracleWithoutLatestRound( - 150e8, - 8 - ); - mockFeed.set(54321, 150e8, 100, 200, 54321); - - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); - - // Get roundId from latestRoundData - (uint80 roundId, , , , ) = proxy.latestRoundData(); - - // Get round from latestRound - uint256 round = proxy.latestRound(); - - // They should match - assertEq( - round, - uint256(roundId), - "latestRound should match latestRoundData roundId" - ); - } -} diff --git a/test/unit/OEVProtocolFeeRedeemer.t.sol b/test/unit/OEVProtocolFeeRedeemer.t.sol new file mode 100644 index 000000000..b5e9c4317 --- /dev/null +++ b/test/unit/OEVProtocolFeeRedeemer.t.sol @@ -0,0 +1,506 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {Test} from "@forge-std/Test.sol"; +import {OEVProtocolFeeRedeemer} from "@protocol/OEVProtocolFeeRedeemer.sol"; +import {MErc20Immutable} from "@test/mock/MErc20Immutable.sol"; +import {MockERC20} from "@test/mock/MockERC20.sol"; +import {MockWeth} from "@test/mock/MockWeth.sol"; +import {Comptroller} from "@protocol/Comptroller.sol"; +import {SimplePriceOracle} from "@test/helper/SimplePriceOracle.sol"; +import {WhitePaperInterestRateModel} from "@protocol/irm/WhitePaperInterestRateModel.sol"; +import {InterestRateModel} from "@protocol/irm/InterestRateModel.sol"; + +contract OEVProtocolFeeRedeemerUnitTest is Test { + OEVProtocolFeeRedeemer public redeemer; + + // Mock contracts + MockWeth public weth; + MockERC20 public usdc; + + // mToken contracts + MErc20Immutable public mWETH; + MErc20Immutable public mUSDC; + + // Protocol contracts + Comptroller public comptroller; + SimplePriceOracle public priceOracle; + InterestRateModel public interestRateModel; + + // Test addresses + address public owner = address(0x1); + address public user = address(0x2); + address public nonOwner = address(0x3); + + // Constants + uint256 public constant INITIAL_EXCHANGE_RATE = 2e17; // 0.2 + uint256 public constant MINT_AMOUNT = 100 ether; + + // Events + event ReservesAddedFromOEV(address indexed mToken, uint256 amount); + + function setUp() public { + // Deploy mock tokens + weth = new MockWeth(); + usdc = new MockERC20(); + + // Deploy comptroller and oracle + comptroller = new Comptroller(); + priceOracle = new SimplePriceOracle(); + + // Set up comptroller + comptroller._setPriceOracle(priceOracle); + comptroller._setCloseFactor(0.5e18); + comptroller._setLiquidationIncentive(1.08e18); + + // Deploy interest rate model (2.5% base rate, 20% slope) + interestRateModel = new WhitePaperInterestRateModel(0.025e18, 0.2e18); + + // Deploy mTokens + mWETH = new MErc20Immutable( + address(weth), + comptroller, + interestRateModel, + INITIAL_EXCHANGE_RATE, + "Moonwell WETH", + "mWETH", + 8, + payable(owner) + ); + + mUSDC = new MErc20Immutable( + address(usdc), + comptroller, + interestRateModel, + INITIAL_EXCHANGE_RATE, + "Moonwell USDC", + "mUSDC", + 8, + payable(owner) + ); + + // Support markets (comptroller admin is address(this) by default) + comptroller._supportMarket(mWETH); + comptroller._supportMarket(mUSDC); + + // Set oracle prices (1 ETH = 2000 USD, 1 USDC = 1 USD) + priceOracle.setUnderlyingPrice(mWETH, 2000e18); + priceOracle.setUnderlyingPrice(mUSDC, 1e18); + + // Deploy OEVProtocolFeeRedeemer + vm.prank(owner); + redeemer = new OEVProtocolFeeRedeemer(address(mWETH)); + + // Fund the redeemer with ETH for testing + vm.deal(address(redeemer), 10 ether); + } + + function testConstructor() public view { + assertEq( + redeemer.MOONWELL_WETH(), + address(mWETH), + "MOONWELL_WETH should be set" + ); + assertEq(redeemer.owner(), owner, "Owner should be set"); + assertTrue( + redeemer.whitelistedMarkets(address(mWETH)), + "mWETH should be whitelisted" + ); + } + + function testConstructorWhitelistsWETH() public view { + assertTrue( + redeemer.whitelistedMarkets(address(mWETH)), + "mWETH should be automatically whitelisted" + ); + assertFalse( + redeemer.whitelistedMarkets(address(mUSDC)), + "mUSDC should not be whitelisted initially" + ); + } + + function testWhitelistMarket() public { + assertFalse( + redeemer.whitelistedMarkets(address(mUSDC)), + "mUSDC should not be whitelisted" + ); + + vm.prank(owner); + redeemer.whitelistMarket(address(mUSDC)); + + assertTrue( + redeemer.whitelistedMarkets(address(mUSDC)), + "mUSDC should be whitelisted" + ); + } + + function testWhitelistMarketOnlyOwner() public { + vm.prank(nonOwner); + vm.expectRevert("Ownable: caller is not the owner"); + redeemer.whitelistMarket(address(mUSDC)); + } + + function testCannotCallRedeemAndAddReservesWithNonWhitelistedMarket() + public + { + vm.expectRevert("OEVProtocolFeeRedeemer: not whitelisted market"); + redeemer.redeemAndAddReserves(address(mUSDC)); + } + + function testCannotCallAddReservesWithNonWhitelistedMarket() public { + vm.expectRevert("OEVProtocolFeeRedeemer: not whitelisted market"); + redeemer.addReserves(address(mUSDC)); + } + + function testRedeemAndAddReserves() public { + // Setup: Ensure mWETH has enough cash by having someone else mint first + weth.mint(owner, MINT_AMOUNT * 10); + vm.startPrank(owner); + weth.approve(address(mWETH), MINT_AMOUNT * 10); + mWETH.mint(MINT_AMOUNT * 10); + vm.stopPrank(); + + // Give redeemer some mWETH tokens + weth.mint(user, MINT_AMOUNT); + + vm.startPrank(user); + weth.approve(address(mWETH), MINT_AMOUNT); + require(mWETH.mint(MINT_AMOUNT) == 0, "mint failed"); + + // Transfer mWETH to redeemer + uint256 mTokenAmount = mWETH.balanceOf(user); + mWETH.transfer(address(redeemer), mTokenAmount); + vm.stopPrank(); + + // Record state before + uint256 reservesBefore = mWETH.totalReserves(); + uint256 redeemerMTokenBalance = mWETH.balanceOf(address(redeemer)); + + assertGt(redeemerMTokenBalance, 0, "Redeemer should have mTokens"); + + // Calculate expected underlying amount + uint256 exchangeRate = mWETH.exchangeRateStored(); + uint256 expectedUnderlying = (redeemerMTokenBalance * exchangeRate) / + 1e18; + + // Execute redeem and add reserves + vm.expectEmit(true, true, true, true); + emit ReservesAddedFromOEV(address(mWETH), expectedUnderlying); + + redeemer.redeemAndAddReserves(address(mWETH)); + + // Verify results + assertEq( + mWETH.balanceOf(address(redeemer)), + 0, + "Redeemer should have no mTokens left" + ); + assertGt( + mWETH.totalReserves(), + reservesBefore, + "Reserves should have increased" + ); + } + + function testRedeemAndAddReservesWithMTokenBalance() public { + // Ensure mWETH has enough cash + weth.mint(owner, MINT_AMOUNT * 10); + vm.startPrank(owner); + weth.approve(address(mWETH), MINT_AMOUNT * 10); + mWETH.mint(MINT_AMOUNT * 10); + vm.stopPrank(); + + // Give redeemer some mWETH + weth.mint(address(this), MINT_AMOUNT); + weth.approve(address(mWETH), MINT_AMOUNT); + require(mWETH.mint(MINT_AMOUNT) == 0, "mint failed"); + + uint256 mTokenBalance = mWETH.balanceOf(address(this)); + mWETH.transfer(address(redeemer), mTokenBalance); + + uint256 reservesBefore = mWETH.totalReserves(); + + redeemer.redeemAndAddReserves(address(mWETH)); + + assertGt( + mWETH.totalReserves(), + reservesBefore, + "Reserves should increase" + ); + assertEq( + mWETH.balanceOf(address(redeemer)), + 0, + "All mTokens should be redeemed" + ); + } + + function testRedeemAndAddReservesRevertsOnInvalidMToken() public { + // First whitelist the weth address (even though it's not an mToken) + vm.prank(owner); + redeemer.whitelistMarket(address(weth)); + + // When calling isMToken() on a contract that doesn't implement it, we get a revert + vm.expectRevert(); + redeemer.redeemAndAddReserves(address(weth)); + } + + function testAddReserves() public { + // Whitelist mUSDC first + vm.prank(owner); + redeemer.whitelistMarket(address(mUSDC)); + + // Give redeemer some USDC + uint256 usdcAmount = 1000e18; + usdc.mint(address(redeemer), usdcAmount); + + uint256 reservesBefore = mUSDC.totalReserves(); + uint256 redeemerBalance = usdc.balanceOf(address(redeemer)); + + assertEq(redeemerBalance, usdcAmount, "Redeemer should have USDC"); + + // Execute add reserves + vm.expectEmit(true, true, true, true); + emit ReservesAddedFromOEV(address(mUSDC), usdcAmount); + + redeemer.addReserves(address(mUSDC)); + + // Verify results + assertEq( + usdc.balanceOf(address(redeemer)), + 0, + "Redeemer should have no USDC left" + ); + assertEq( + mUSDC.totalReserves(), + reservesBefore + usdcAmount, + "Reserves should increase by exact amount" + ); + } + + function testAddReservesRevertsWithZeroBalance() public { + vm.prank(owner); + redeemer.whitelistMarket(address(mUSDC)); + + vm.expectRevert("OEVProtocolFeeRedeemer: no underlying balance"); + redeemer.addReserves(address(mUSDC)); + } + + function testAddReservesRevertsOnInvalidMToken() public { + vm.prank(owner); + redeemer.whitelistMarket(address(usdc)); + + // Give the redeemer some USDC so it doesn't fail on the balance check first + usdc.mint(address(redeemer), 1000e18); + + // When calling isMToken() on a contract that doesn't implement it, we get a revert + vm.expectRevert(); + redeemer.addReserves(address(usdc)); + } + + function testAddReservesNative() public { + uint256 nativeAmount = 5 ether; + vm.deal(address(redeemer), nativeAmount); + + uint256 reservesBefore = mWETH.totalReserves(); + uint256 nativeBalance = address(redeemer).balance; + + assertEq( + nativeBalance, + nativeAmount, + "Redeemer should have native balance" + ); + + // Execute add reserves native + vm.expectEmit(true, true, true, true); + emit ReservesAddedFromOEV(address(mWETH), nativeAmount); + + redeemer.addReservesNative(); + + // Verify results + assertEq( + address(redeemer).balance, + 0, + "Redeemer should have no native balance left" + ); + assertEq( + mWETH.totalReserves(), + reservesBefore + nativeAmount, + "Reserves should increase by native amount" + ); + } + + function testAddReservesNativeRevertsWithZeroBalance() public { + // Drain native balance + vm.deal(address(redeemer), 0); + + vm.expectRevert("OEVProtocolFeeRedeemer: no native balance"); + redeemer.addReservesNative(); + } + + function testAddReservesNativeWrapsETHToWETH() public { + uint256 nativeAmount = 3 ether; + vm.deal(address(redeemer), nativeAmount); + + uint256 wethBalanceBefore = weth.balanceOf(address(mWETH)); + + redeemer.addReservesNative(); + + // Verify WETH was created and transferred + assertEq(address(redeemer).balance, 0, "Native ETH should be consumed"); + assertGt( + weth.balanceOf(address(mWETH)), + wethBalanceBefore, + "WETH balance of mWETH should increase" + ); + } + + function testMultipleOperationsInSequence() public { + // Whitelist mUSDC + vm.prank(owner); + redeemer.whitelistMarket(address(mUSDC)); + + // 1. Add reserves from native + uint256 nativeAmount = 2 ether; + vm.deal(address(redeemer), nativeAmount); + redeemer.addReservesNative(); + + uint256 reservesAfterNative = mWETH.totalReserves(); + assertGt( + reservesAfterNative, + 0, + "Reserves should increase after native" + ); + + // 2. Add reserves from underlying token + uint256 usdcAmount = 500e18; + usdc.mint(address(redeemer), usdcAmount); + redeemer.addReserves(address(mUSDC)); + + assertEq( + mUSDC.totalReserves(), + usdcAmount, + "USDC reserves should match" + ); + + // 3. Redeem and add reserves + // First ensure mWETH has enough cash + weth.mint(owner, MINT_AMOUNT * 10); + vm.startPrank(owner); + weth.approve(address(mWETH), MINT_AMOUNT * 10); + mWETH.mint(MINT_AMOUNT * 10); + vm.stopPrank(); + + weth.mint(address(this), MINT_AMOUNT); + weth.approve(address(mWETH), MINT_AMOUNT); + require(mWETH.mint(MINT_AMOUNT) == 0, "mint failed"); + mWETH.transfer(address(redeemer), mWETH.balanceOf(address(this))); + + uint256 reservesBeforeRedeem = mWETH.totalReserves(); + redeemer.redeemAndAddReserves(address(mWETH)); + + assertGt( + mWETH.totalReserves(), + reservesBeforeRedeem, + "Reserves should increase after redeem" + ); + } + + function testReceiveETHDirectly() public { + uint256 amount = 1 ether; + uint256 initialBalance = address(redeemer).balance; + + // Send ETH directly to redeemer + (bool success, ) = address(redeemer).call{value: amount}(""); + assertTrue(success, "Should be able to receive ETH"); + assertEq( + address(redeemer).balance, + initialBalance + amount, + "Balance should match sent amount" + ); + } + + function testPermissionlessExecution() public { + // Setup: Give redeemer some native balance + vm.deal(address(redeemer), 5 ether); + + // Any address should be able to call these functions + vm.prank(user); + redeemer.addReservesNative(); + + vm.deal(address(redeemer), 5 ether); + vm.prank(nonOwner); + redeemer.addReservesNative(); + } + + function testWhitelistMultipleMarkets() public { + address[] memory markets = new address[](2); + markets[0] = address(mUSDC); + markets[1] = address(0x123); + + vm.startPrank(owner); + for (uint256 i = 0; i < markets.length; i++) { + redeemer.whitelistMarket(markets[i]); + assertTrue( + redeemer.whitelistedMarkets(markets[i]), + "Market should be whitelisted" + ); + } + vm.stopPrank(); + } + + function testRedeemAndAddReservesWithZeroMTokenBalance() public { + // Ensure mWETH has enough cash + weth.mint(owner, MINT_AMOUNT); + vm.startPrank(owner); + weth.approve(address(mWETH), MINT_AMOUNT); + mWETH.mint(MINT_AMOUNT); + vm.stopPrank(); + + // Redeemer has no mTokens + assertEq( + mWETH.balanceOf(address(redeemer)), + 0, + "Should start with 0 mTokens" + ); + + uint256 reservesBefore = mWETH.totalReserves(); + + // Should not revert, but also should not change reserves + redeemer.redeemAndAddReserves(address(mWETH)); + + assertEq( + mWETH.totalReserves(), + reservesBefore, + "Reserves should not change" + ); + } + + function testAddReservesAfterWhitelistingLater() public { + // Try to add reserves before whitelisting - should fail + usdc.mint(address(redeemer), 1000e18); + + vm.expectRevert("OEVProtocolFeeRedeemer: not whitelisted market"); + redeemer.addReserves(address(mUSDC)); + + // Whitelist the market + vm.prank(owner); + redeemer.whitelistMarket(address(mUSDC)); + + // Now it should work + redeemer.addReserves(address(mUSDC)); + assertGt(mUSDC.totalReserves(), 0, "Reserves should be added"); + } + + function testLargeAmounts() public { + uint256 largeAmount = 1000000 ether; + + // Test with large native amount + vm.deal(address(redeemer), largeAmount); + redeemer.addReservesNative(); + + assertEq(address(redeemer).balance, 0, "Should consume all native ETH"); + assertGt(mWETH.totalReserves(), 0, "Should add large reserves"); + } + + receive() external payable {} +} diff --git a/test/utils/Liquidations.sol b/test/utils/Liquidations.sol new file mode 100644 index 000000000..7cf1f751a --- /dev/null +++ b/test/utils/Liquidations.sol @@ -0,0 +1,177 @@ +//SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +import {BASE_CHAIN_ID} from "@utils/ChainIds.sol"; + +/// @notice Struct to represent a liquidation event +struct LiquidationData { + uint256 timestamp; + uint256 blockNumber; + string borrowedToken; + string collateralToken; + string borrowMTokenKey; + string collateralMTokenKey; + address borrower; + address liquidator; + uint256 repayAmount; + uint256 seizedCollateralAmount; + uint256 liquidationSizeUSD; +} + +/// @notice Struct to hold liquidation state +struct LiquidationState { + uint256 borrowerBorrowBefore; + uint256 borrowerCollateralBefore; + uint256 reservesBefore; + uint256 borrowerBorrowAfter; + uint256 borrowerCollateralAfter; + uint256 reservesAfter; + uint256 protocolFee; // in mToken units + uint256 liquidatorFeeReceived; // in mToken units + uint256 protocolFeeRedeemed; // underlying tokens added to reserves after redemption +} + +/// @notice Abstract contract to provide liquidation data from 10/10; used by ChainlinkOEVWrapperIntegration.t.sol +/// https://dune.com/queries/4326964/7267425 +abstract contract Liquidations { + /// @notice Mapping from chainId to liquidation data array + mapping(uint256 => LiquidationData[]) internal _liquidationsByChain; + + constructor() { + // Base chain liquidations (chainId: 8453) + // https://basescan.org/tx/0x24782acfe7faef5bad1e4545a617c733a632e0aa20810f4500fbaf2c05354c7f + _liquidationsByChain[BASE_CHAIN_ID].push( + LiquidationData({ + timestamp: 1760131433, + blockNumber: 36671043, + borrowedToken: "USDC", + collateralToken: "AERO", + borrowMTokenKey: "MOONWELL_USDC", + collateralMTokenKey: "MOONWELL_AERO", + borrower: 0x46560b7207bb490A2115c334E36a70D6aD4BdEBD, + liquidator: 0x4de911f6b0a3ACE9c25cf198Fe6027415051Eb60, + repayAmount: 409205466639, + seizedCollateralAmount: 32669011298294140000000000, + liquidationSizeUSD: 409201783789800250000000000000000 + }) + ); + + // https://basescan.org/tx/0xf83352d2f10aa4ab985516e5514d3ec3593fe9e66b1f5093af7fabff211d1fb8 + // _liquidationsByChain[BASE_CHAIN_ID].push( + // LiquidationData({ + // timestamp: 1760131433, + // blockNumber: 36671043, + // borrowedToken: "USDC", + // collateralToken: "AERO", + // borrowMTokenKey: "MOONWELL_USDC", + // collateralMTokenKey: "MOONWELL_AERO", + // borrower: 0x2F9677016cB1e92e8F8a999c4541650C80C8637A, + // liquidator: 0x4de911f6b0a3ACE9c25cf198Fe6027415051Eb60, + // repayAmount: 238226106376, + // seizedCollateralAmount: 19018835267936348228550656, + // liquidationSizeUSD: 238223962341042611390266940208002287796224 + // }) + // ); + + // https://basescan.org/tx/0xf913f30fc0d2cdafe3e9a0f1cf82a1d0d9cda19549031f60a765960108b275ee + // _liquidationsByChain[BASE_CHAIN_ID].push( + // LiquidationData({ + // timestamp: 1760131529, + // blockNumber: 36671091, + // borrowedToken: "USDC", + // collateralToken: "AERO", + // borrowMTokenKey: "MOONWELL_USDC", + // collateralMTokenKey: "MOONWELL_AERO", + // borrower: 0x2F9677016cB1e92e8F8a999c4541650C80C8637A, + // liquidator: 0xDD50cD62869d41961f052522d6E20069F82b9DFA, + // repayAmount: 118654047697, + // seizedCollateralAmount: 11711121057389770298097664, + // liquidationSizeUSD: 118659980399384857607665087350398856986624 + // }) + // ); + + // // https://basescan.org/tx/0x44831fafe2b261dc91717dab8ca521c3b0ed70c0e836b4419b09e58028f84205 + // _liquidationsByChain[BASE_CHAIN_ID].push( + // LiquidationData({ + // timestamp: 1760130921, + // blockNumber: 36670787, + // borrowedToken: "USDC", + // collateralToken: "cbETH", + // borrowMTokenKey: "MOONWELL_USDC", + // collateralMTokenKey: "MOONWELL_cbETH", + // borrower: 0xa4E057E58a11de90f6e63dC9B6c51025bD2c9646, + // liquidator: 0xeEEc65C4987a3a0B60Bd535C011080e544Acb1aE, + // repayAmount: 17871319504, + // seizedCollateralAmount: 240401644490000000000, + // liquidationSizeUSD: 17866690832248463000000000000000000 + // }) + // ); + + // // https://basescan.org/tx/0x0603c93de1f96debf8e8759fd483ff66ccfdc97b3efce9678dfb640e193f3551 + // _liquidationsByChain[BASE_CHAIN_ID].push( + // LiquidationData({ + // timestamp: 1760131153, + // blockNumber: 36670903, + // borrowedToken: "USDC", + // collateralToken: "cbETH", + // borrowMTokenKey: "MOONWELL_USDC", + // collateralMTokenKey: "MOONWELL_cbETH", + // borrower: 0x35Fdad33177B6ACC608091E2Cc1F4b22FA2D6D89, + // liquidator: 0x4de911f6b0a3ACE9c25cf198Fe6027415051Eb60, + // repayAmount: 890915180, + // seizedCollateralAmount: 12350477230000000000, + // liquidationSizeUSD: 890907161763379900000000000000000 + // }) + // ); + + // // https://basescan.org/tx/0xbeaf7b851a7c56786779e9c84a748c1a5fdb14349e0b8de21d3ae4df2411ed29 + // _liquidationsByChain[BASE_CHAIN_ID].push( + // LiquidationData({ + // timestamp: 1728595740, + // blockNumber: 20456036, + // borrowedToken: "USDC", + // collateralToken: "cbETH", + // borrowMTokenKey: "MOONWELL_USDC", + // collateralMTokenKey: "MOONWELL_cbETH", + // borrower: 0x9f110445ed4389ec1c4ca3edf69e13d6ac28b103, + // liquidator: 0x4de911f6b0a3ace9c25cf198fe6027415051eb60, + // repayAmount: 1568017162, + // seizedCollateralAmount: 22119741600000000000, + // liquidationSizeUSD: 1568003049845542100000000000000000 + // }) + // ); + } + + /// @notice Get liquidation data for the current chain + function getLiquidations() public view returns (LiquidationData[] memory) { + LiquidationData[] storage chainLiquidations = _liquidationsByChain[ + block.chainid + ]; + LiquidationData[] memory liquidations = new LiquidationData[]( + chainLiquidations.length + ); + + unchecked { + uint256 liquidationsLength = liquidations.length; + for (uint256 i = 0; i < liquidationsLength; i++) { + liquidations[i] = LiquidationData({ + timestamp: chainLiquidations[i].timestamp, + blockNumber: chainLiquidations[i].blockNumber, + borrowedToken: chainLiquidations[i].borrowedToken, + collateralToken: chainLiquidations[i].collateralToken, + borrowMTokenKey: chainLiquidations[i].borrowMTokenKey, + collateralMTokenKey: chainLiquidations[i] + .collateralMTokenKey, + borrower: chainLiquidations[i].borrower, + liquidator: chainLiquidations[i].liquidator, + repayAmount: chainLiquidations[i].repayAmount, + seizedCollateralAmount: chainLiquidations[i] + .seizedCollateralAmount, + liquidationSizeUSD: chainLiquidations[i].liquidationSizeUSD + }); + } + } + + return liquidations; + } +}