From 7ba14e916d4dcd9cd63827b2057c5f4189454e9a Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Wed, 15 Oct 2025 16:58:55 -0300 Subject: [PATCH 01/31] Add OEV fix to ChainlinkOracleProxy --- src/oracles/ChainlinkOracleProxy.sol | 177 ++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 5 deletions(-) diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOracleProxy.sol index 6ab3957a7..c02de0401 100644 --- a/src/oracles/ChainlinkOracleProxy.sol +++ b/src/oracles/ChainlinkOracleProxy.sol @@ -3,8 +3,11 @@ pragma solidity 0.8.19; import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import {MErc20Interface} from "../MTokenInterfaces.sol"; import "./AggregatorV3Interface.sol"; - +import {MErc20Storage} from "../MTokenInterfaces.sol"; +import {EIP20Interface} from "../EIP20Interface.sol"; +import {MTokenInterface} from "../MTokenInterfaces.sol"; /** * @title ChainlinkOracleProxy * @notice A TransparentUpgradeableProxy compliant contract that implements AggregatorV3Interface @@ -15,9 +18,46 @@ contract ChainlinkOracleProxy is OwnableUpgradeable, AggregatorV3Interface { + /// @notice The maximum basis points for the fee multiplier + uint16 public constant MAX_BPS = 10000; + /// @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 + /// @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 price is updated early and liquidated + event PriceUpdatedEarlyAndLiquidated( + address indexed sender, + address indexed borrower, + uint256 repayAmount, + address indexed mToken, + uint256 fee + ); + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -27,8 +67,19 @@ contract ChainlinkOracleProxy is * @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 _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 initialize(address _priceFeed, address _owner) public initializer { + function initialize( + address _priceFeed, + address _owner, + address _feeRecipient, + uint16 _feeMultiplier, + uint256 _maxRoundDelay, + uint256 _maxDecrements + ) public initializer { require( _priceFeed != address(0), "ChainlinkOracleProxy: price feed cannot be zero address" @@ -37,14 +88,31 @@ contract ChainlinkOracleProxy is _owner != address(0), "ChainlinkOracleProxy: owner cannot be zero address" ); - + require( + _feeRecipient != address(0), + "ChainlinkOracleProxy: fee recipient cannot be zero address" + ); + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOracleProxy: fee multiplier cannot be greater than MAX_BPS" + ); + require( + _maxRoundDelay > 0, + "ChainlinkOracleProxy: max round delay cannot be zero" + ); + require( + _maxDecrements > 0, + "ChainlinkOracleProxy: max decrements cannot be zero" + ); __Ownable_init(); priceFeed = AggregatorV3Interface(_priceFeed); _transferOwnership(_owner); - } - // AggregatorV3Interface implementation - forwards all calls to the configured price feed + cachedRoundId = priceFeed.latestRound(); + maxRoundDelay = _maxRoundDelay; + maxDecrements = _maxDecrements; + } function decimals() external view override returns (uint8) { return priceFeed.decimals(); @@ -91,6 +159,37 @@ contract ChainlinkOracleProxy is { (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed .latestRoundData(); + + // Return the current round data if either: + // 1. This round has already been cached (meaning someone paid for it) + // 2. The round is too old + + if ( + roundId != cachedRoundId && + block.timestamp < updatedAt + maxRoundDelay + ) { + // If the current round is not too old and hasn't been paid for, + // attempt to find the most recent valid round by checking previous rounds + uint256 startRoundId = roundId; + + for (uint256 i = 0; i < maxDecrements && --startRoundId > 0; i++) { + try priceFeed.getRoundData(uint80(startRoundId)) returns ( + uint80 r, + int256 a, + uint256 s, + uint256 u, + uint80 ar + ) { + roundId = r; + answer = a; + startedAt = s; + updatedAt = u; + answeredInRound = ar; + break; + } catch {} + } + } + _validateRoundData(roundId, answer, updatedAt, answeredInRound); } @@ -104,6 +203,28 @@ contract ChainlinkOracleProxy is } } + function setFeeRecipient(address _feeRecipient) external onlyOwner { + require( + _feeRecipient != address(0), + "ChainlinkOracleProxy: fee recipient cannot be zero address" + ); + + address oldFeeRecipient = feeRecipient; + feeRecipient = _feeRecipient; + + emit FeeRecipientChanged(oldFeeRecipient, _feeRecipient); + } + + function setFeeMultiplier(uint16 _feeMultiplier) external onlyOwner { + require( + _feeMultiplier <= MAX_BPS, + "ChainlinkOracleProxy: fee multiplier cannot be greater than MAX_BPS" + ); + uint16 oldFeeMultiplier = feeMultiplier; + feeMultiplier = _feeMultiplier; + emit FeeMultiplierChanged(oldFeeMultiplier, _feeMultiplier); + } + /// @notice Validate the round data from Chainlink /// @param roundId The round ID to validate /// @param answer The price to validate @@ -119,4 +240,50 @@ contract ChainlinkOracleProxy is require(updatedAt != 0, "Round is in incompleted state"); require(answeredInRound >= roundId, "Stale price"); } + + function updatePriceEarlyAndLiquidate( + address borrower, + uint256 repayAmount, + address mToken + ) external { + uint256 fee = (repayAmount * uint256(feeMultiplier)) / MAX_BPS; + require(fee > 0, "ChainlinkOracleProxy: fee cannot be zero"); + + EIP20Interface underlying = EIP20Interface( + MErc20Storage(mToken).underlying() + ); + + address recipient = feeRecipient == address(0) ? owner() : feeRecipient; + underlying.transferFrom(msg.sender, recipient, fee); + + _updatePriceEarly(); + + MErc20Interface(mToken).liquidateBorrow( + borrower, + repayAmount, + MTokenInterface(mToken) + ); + + emit PriceUpdatedEarlyAndLiquidated( + msg.sender, + borrower, + repayAmount, + mToken, + fee + ); + } + + function _updatePriceEarly() internal returns (uint80) { + ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = priceFeed.latestRoundData(); + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + + cachedRoundId = roundId; + return roundId; + } } From aa458951a69b75ba349ab966e29c7251a58ab6f0 Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 10:20:24 -0300 Subject: [PATCH 02/31] add natspec comments --- src/oracles/ChainlinkOracleProxy.sol | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOracleProxy.sol index c02de0401..4a33bdfb3 100644 --- a/src/oracles/ChainlinkOracleProxy.sol +++ b/src/oracles/ChainlinkOracleProxy.sol @@ -114,18 +114,39 @@ contract ChainlinkOracleProxy is maxDecrements = _maxDecrements; } + /** + * @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 ) @@ -145,6 +166,16 @@ contract ChainlinkOracleProxy is _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 @@ -193,6 +224,11 @@ contract ChainlinkOracleProxy is _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; @@ -203,6 +239,10 @@ contract ChainlinkOracleProxy is } } + /** + * @notice Sets the fee recipient address + * @param _feeRecipient The new fee recipient address + */ function setFeeRecipient(address _feeRecipient) external onlyOwner { require( _feeRecipient != address(0), @@ -215,6 +255,10 @@ contract ChainlinkOracleProxy is 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, @@ -241,6 +285,13 @@ contract ChainlinkOracleProxy is 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 + * @param borrower The address of the borrower to liquidate + * @param repayAmount The amount to repay on behalf of the borrower + * @param mToken The mToken market in which to perform the liquidation + */ function updatePriceEarlyAndLiquidate( address borrower, uint256 repayAmount, @@ -273,6 +324,11 @@ contract ChainlinkOracleProxy is ); } + /** + * @notice Internal function to update the cached round ID to the latest round + * @dev This allows callers to bypass the OEV protection and access the latest price + * @return The updated round ID + */ function _updatePriceEarly() internal returns (uint80) { ( uint80 roundId, From 1fc590895db3dc1d30bca0de9fcd2d6b6178d0cf Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 10:25:35 -0300 Subject: [PATCH 03/31] approve first --- src/oracles/ChainlinkOracleProxy.sol | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOracleProxy.sol index 4a33bdfb3..0a0f055d5 100644 --- a/src/oracles/ChainlinkOracleProxy.sol +++ b/src/oracles/ChainlinkOracleProxy.sol @@ -309,6 +309,10 @@ contract ChainlinkOracleProxy is _updatePriceEarly(); + // first approve the mToken to spent the tokens + underlying.approve(mToken, repayAmount); + + // then liquidate the borrow MErc20Interface(mToken).liquidateBorrow( borrower, repayAmount, From c1fad883dc483358d9899f67b9a80392f658c81d Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 10:26:28 -0300 Subject: [PATCH 04/31] add todo --- src/oracles/ChainlinkOracleProxy.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOracleProxy.sol index 0a0f055d5..52727433b 100644 --- a/src/oracles/ChainlinkOracleProxy.sol +++ b/src/oracles/ChainlinkOracleProxy.sol @@ -319,6 +319,8 @@ contract ChainlinkOracleProxy is MTokenInterface(mToken) ); + // TODO redeem mToken and withdraw the underlying tokens to the recipient + emit PriceUpdatedEarlyAndLiquidated( msg.sender, borrower, From 0a29669b8e240ac43575bb57af5ea23764095ece Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Thu, 16 Oct 2025 09:05:55 -0500 Subject: [PATCH 05/31] fix compile issues --- script/templates/CreateMorphoMarket.s.sol | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/script/templates/CreateMorphoMarket.s.sol b/script/templates/CreateMorphoMarket.s.sol index 063b6f3cf..94ad80177 100644 --- a/script/templates/CreateMorphoMarket.s.sol +++ b/script/templates/CreateMorphoMarket.s.sol @@ -45,6 +45,10 @@ contract CreateMorphoMarket is Script, Test { 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; + function run() external { // Setup fork for Base chain BASE_FORK_ID.createForksAndSelect(); @@ -247,7 +251,11 @@ contract CreateMorphoMarket is Script, Test { ChainlinkOracleProxy(address(proxy)).initialize( addresses.getAddress(ocfg.baseFeedName), - msg.sender + msg.sender, // TODO: should be the Temporal Governor? + addresses.getAddress("OEV_FEE_RECIPIENT"), + FEE_MULTIPLIER, + MAX_ROUND_DELAY, + MAX_DECREMENTS ); return AggregatorV3Interface(address(proxy)); From f84fc0169bbb10b599d0c0aaa9df23f255605f3f Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 11:31:48 -0300 Subject: [PATCH 06/31] rewrite latestRoundData logic for clarity --- ...racleProxy.sol => ChainlinkOEVWrapper.sol} | 109 ++++++++++-------- 1 file changed, 64 insertions(+), 45 deletions(-) rename src/oracles/{ChainlinkOracleProxy.sol => ChainlinkOEVWrapper.sol} (79%) diff --git a/src/oracles/ChainlinkOracleProxy.sol b/src/oracles/ChainlinkOEVWrapper.sol similarity index 79% rename from src/oracles/ChainlinkOracleProxy.sol rename to src/oracles/ChainlinkOEVWrapper.sol index 52727433b..23bf43319 100644 --- a/src/oracles/ChainlinkOracleProxy.sol +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -9,11 +9,11 @@ import {MErc20Storage} from "../MTokenInterfaces.sol"; import {EIP20Interface} from "../EIP20Interface.sol"; import {MTokenInterface} from "../MTokenInterfaces.sol"; /** - * @title ChainlinkOracleProxy - * @notice A TransparentUpgradeableProxy compliant contract that implements AggregatorV3Interface - * and forwards calls to a configurable Chainlink price feed + * @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 ChainlinkOracleProxy is +contract ChainlinkOEVWrapper is Initializable, OwnableUpgradeable, AggregatorV3Interface @@ -107,11 +107,11 @@ contract ChainlinkOracleProxy is __Ownable_init(); priceFeed = AggregatorV3Interface(_priceFeed); - _transferOwnership(_owner); - cachedRoundId = priceFeed.latestRound(); maxRoundDelay = _maxRoundDelay; maxDecrements = _maxDecrements; + + _transferOwnership(_owner); } /** @@ -191,36 +191,36 @@ contract ChainlinkOracleProxy is (roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed .latestRoundData(); - // Return the current round data if either: - // 1. This round has already been cached (meaning someone paid for it) - // 2. The round is too old + // 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) { - if ( - roundId != cachedRoundId && - block.timestamp < updatedAt + maxRoundDelay - ) { - // If the current round is not too old and hasn't been paid for, - // attempt to find the most recent valid round by checking previous rounds - uint256 startRoundId = roundId; + + // start from the previous round + uint256 currentRoundId = roundId - 1; - for (uint256 i = 0; i < maxDecrements && --startRoundId > 0; i++) { - try priceFeed.getRoundData(uint80(startRoundId)) returns ( + 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 {} + } catch { + // previous round data not found, continue to the next decrement + currentRoundId--; + } } } - _validateRoundData(roundId, answer, updatedAt, answeredInRound); } @@ -297,29 +297,65 @@ contract ChainlinkOracleProxy is uint256 repayAmount, address mToken ) external { + // ensure the repay amount is greater than zero + require(repayAmount > 0, "ChainlinkOracleProxy: repay amount cannot be zero"); + + // ensure the borrower is not the zero address + require(borrower != address(0), "ChainlinkOracleProxy: borrower cannot be zero address"); + + // ensure the mToken is not the zero address + require(mToken != address(0), "ChainlinkOracleProxy: mToken cannot be zero address"); + + // calculate the protocol fee based on the fee multiplier uint256 fee = (repayAmount * uint256(feeMultiplier)) / MAX_BPS; + + // ensure the fee is greater than zero require(fee > 0, "ChainlinkOracleProxy: fee cannot be zero"); + // get the collateral underlying token EIP20Interface underlying = EIP20Interface( MErc20Storage(mToken).underlying() ); + // if the fee recipient is not set, use the owner as the recipient address recipient = feeRecipient == address(0) ? owner() : feeRecipient; - underlying.transferFrom(msg.sender, recipient, fee); - _updatePriceEarly(); + // transfer all the repay amount to the contract to cover the liquidation + underlying.transferFrom(msg.sender, address(this), repayAmount); + + // get the latest round data + ( + uint80 roundId, + int256 answer, + , // startedAt + uint256 updatedAt, + uint80 answeredInRound + ) = priceFeed.latestRoundData(); + + // validate the round data + _validateRoundData(roundId, answer, updatedAt, answeredInRound); + + // update the cached round id + cachedRoundId = roundId; - // first approve the mToken to spent the tokens + // approve the mToken to spent the tokens underlying.approve(mToken, repayAmount); - // then liquidate the borrow - MErc20Interface(mToken).liquidateBorrow( + // liquidate the borrower's collateral + require(MErc20Interface(mToken).liquidateBorrow( borrower, repayAmount, MTokenInterface(mToken) - ); + ) == 0, "ChainlinkOracleProxy: liquidation failed"); + + // redeem mToken and withdraw the underlying tokens to the contract + require(MErc20Interface(mToken).redeem(repayAmount) == 0, "ChainlinkOracleProxy: redemption failed"); + + // transfer the protocol fee to the recipient + underlying.transferFrom(address(this), recipient, fee); - // TODO redeem mToken and withdraw the underlying tokens to the recipient + // transfer the remaining underlying tokens to the caller + underlying.transferFrom(address(this), msg.sender, repayAmount - fee); emit PriceUpdatedEarlyAndLiquidated( msg.sender, @@ -330,22 +366,5 @@ contract ChainlinkOracleProxy is ); } - /** - * @notice Internal function to update the cached round ID to the latest round - * @dev This allows callers to bypass the OEV protection and access the latest price - * @return The updated round ID - */ - function _updatePriceEarly() internal returns (uint80) { - ( - uint80 roundId, - int256 answer, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) = priceFeed.latestRoundData(); - _validateRoundData(roundId, answer, updatedAt, answeredInRound); - - cachedRoundId = roundId; - return roundId; - } + } From 51ef38dab01bd77db75dd1486db2716c2c5c4ea8 Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 12:06:30 -0300 Subject: [PATCH 07/31] update errors description --- src/oracles/ChainlinkOEVWrapper.sol | 76 +++++++++++++++++------------ 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol index 23bf43319..776626a03 100644 --- a/src/oracles/ChainlinkOEVWrapper.sol +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -82,27 +82,27 @@ contract ChainlinkOEVWrapper is ) public initializer { require( _priceFeed != address(0), - "ChainlinkOracleProxy: price feed cannot be zero address" + "ChainlinkOEVWrapper: price feed cannot be zero address" ); require( _owner != address(0), - "ChainlinkOracleProxy: owner cannot be zero address" + "ChainlinkOEVWrapper: owner cannot be zero address" ); require( _feeRecipient != address(0), - "ChainlinkOracleProxy: fee recipient cannot be zero address" + "ChainlinkOEVWrapper: fee recipient cannot be zero address" ); require( _feeMultiplier <= MAX_BPS, - "ChainlinkOracleProxy: fee multiplier cannot be greater than MAX_BPS" + "ChainlinkOEVWrapper: fee multiplier cannot be greater than MAX_BPS" ); require( _maxRoundDelay > 0, - "ChainlinkOracleProxy: max round delay cannot be zero" + "ChainlinkOEVWrapper: max round delay cannot be zero" ); require( _maxDecrements > 0, - "ChainlinkOracleProxy: max decrements cannot be zero" + "ChainlinkOEVWrapper: max decrements cannot be zero" ); __Ownable_init(); @@ -194,13 +194,14 @@ contract ChainlinkOEVWrapper is // 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++) { + 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, @@ -208,7 +209,7 @@ contract ChainlinkOEVWrapper is uint256 u, uint80 ar ) { - // previous round data found, update the round data + // previous round data found, update the round data roundId = r; answer = a; startedAt = s; @@ -246,7 +247,7 @@ contract ChainlinkOEVWrapper is function setFeeRecipient(address _feeRecipient) external onlyOwner { require( _feeRecipient != address(0), - "ChainlinkOracleProxy: fee recipient cannot be zero address" + "ChainlinkOEVWrapper: fee recipient cannot be zero address" ); address oldFeeRecipient = feeRecipient; @@ -262,7 +263,7 @@ contract ChainlinkOEVWrapper is function setFeeMultiplier(uint16 _feeMultiplier) external onlyOwner { require( _feeMultiplier <= MAX_BPS, - "ChainlinkOracleProxy: fee multiplier cannot be greater than MAX_BPS" + "ChainlinkOEVWrapper: fee multiplier cannot be greater than MAX_BPS" ); uint16 oldFeeMultiplier = feeMultiplier; feeMultiplier = _feeMultiplier; @@ -298,19 +299,28 @@ contract ChainlinkOEVWrapper is address mToken ) external { // ensure the repay amount is greater than zero - require(repayAmount > 0, "ChainlinkOracleProxy: repay amount cannot be zero"); + require( + repayAmount > 0, + "ChainlinkOEVWrapper: repay amount cannot be zero" + ); // ensure the borrower is not the zero address - require(borrower != address(0), "ChainlinkOracleProxy: borrower cannot be zero address"); + require( + borrower != address(0), + "ChainlinkOEVWrapper: borrower cannot be zero address" + ); // ensure the mToken is not the zero address - require(mToken != address(0), "ChainlinkOracleProxy: mToken cannot be zero address"); + require( + mToken != address(0), + "ChainlinkOEVWrapper: mToken cannot be zero address" + ); // calculate the protocol fee based on the fee multiplier uint256 fee = (repayAmount * uint256(feeMultiplier)) / MAX_BPS; // ensure the fee is greater than zero - require(fee > 0, "ChainlinkOracleProxy: fee cannot be zero"); + require(fee > 0, "ChainlinkOEVWrapper: fee cannot be zero"); // get the collateral underlying token EIP20Interface underlying = EIP20Interface( @@ -326,8 +336,8 @@ contract ChainlinkOEVWrapper is // get the latest round data ( uint80 roundId, - int256 answer, - , // startedAt + int256 answer, // startedAt + , uint256 updatedAt, uint80 answeredInRound ) = priceFeed.latestRoundData(); @@ -342,19 +352,25 @@ contract ChainlinkOEVWrapper is underlying.approve(mToken, repayAmount); // liquidate the borrower's collateral - require(MErc20Interface(mToken).liquidateBorrow( - borrower, - repayAmount, - MTokenInterface(mToken) - ) == 0, "ChainlinkOracleProxy: liquidation failed"); + require( + MErc20Interface(mToken).liquidateBorrow( + borrower, + repayAmount, + MTokenInterface(mToken) + ) == 0, + "ChainlinkOEVWrapper: liquidation failed" + ); // redeem mToken and withdraw the underlying tokens to the contract - require(MErc20Interface(mToken).redeem(repayAmount) == 0, "ChainlinkOracleProxy: redemption failed"); + require( + MErc20Interface(mToken).redeem(repayAmount) == 0, + "ChainlinkOEVWrapper: redemption failed" + ); // transfer the protocol fee to the recipient underlying.transferFrom(address(this), recipient, fee); - // transfer the remaining underlying tokens to the caller + // transfer the remaining underlying tokens to the caller underlying.transferFrom(address(this), msg.sender, repayAmount - fee); emit PriceUpdatedEarlyAndLiquidated( @@ -365,6 +381,4 @@ contract ChainlinkOEVWrapper is fee ); } - - } From 35074fb400e743560232205d75a601701fc94a1d Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Thu, 16 Oct 2025 10:18:46 -0500 Subject: [PATCH 08/31] fix imports --- ....s.sol => DeployChainlinkOEVWrapper.s.sol} | 27 +++++----- script/DeployChainlinkOEVWrapper.sol | 26 ---------- script/templates/CreateMorphoMarket.s.sol | 8 +-- .../ChainlinkOracleProxyIntegration.t.sol | 52 +++++++++---------- test/unit/ChainlinkOracleProxyUnit.t.sol | 22 +++----- 5 files changed, 52 insertions(+), 83 deletions(-) rename script/{DeployChainlinkOracleProxy.s.sol => DeployChainlinkOEVWrapper.s.sol} (76%) delete mode 100644 script/DeployChainlinkOEVWrapper.sol diff --git a/script/DeployChainlinkOracleProxy.s.sol b/script/DeployChainlinkOEVWrapper.s.sol similarity index 76% rename from script/DeployChainlinkOracleProxy.s.sol rename to script/DeployChainlinkOEVWrapper.s.sol index 5b90d3c99..cbd818a1b 100644 --- a/script/DeployChainlinkOracleProxy.s.sol +++ b/script/DeployChainlinkOEVWrapper.s.sol @@ -6,24 +6,25 @@ 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 {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; -contract DeployChainlinkOracleProxy is Script { +// TODO: update for new ChainlinkOEVWrapper +contract DeployChainlinkOEVWrapper is Script { function deploy( Addresses addresses - ) public returns (TransparentUpgradeableProxy, ChainlinkOracleProxy) { + ) public returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { vm.startBroadcast(); // Deploy the implementation contract - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); // Get the ProxyAdmin address address proxyAdmin = addresses.getAddress("MRD_PROXY_ADMIN"); // Prepare initialization data bytes memory initData = abi.encodeWithSelector( - ChainlinkOracleProxy.initialize.selector, + ChainlinkOEVWrapper.initialize.selector, addresses.getAddress("CHAINLINK_WELL_USD"), // Price feed address addresses.getAddress("MRD_PROXY_ADMIN") // Owner address ); @@ -50,7 +51,7 @@ contract DeployChainlinkOracleProxy is Script { function validate( Addresses addresses, TransparentUpgradeableProxy proxy, - ChainlinkOracleProxy implementation + ChainlinkOEVWrapper implementation ) public view { // Get proxy admin contract ProxyAdmin proxyAdmin = ProxyAdmin( @@ -67,39 +68,39 @@ contract DeployChainlinkOracleProxy is Script { require( actualImplementation == address(implementation), - "DeployChainlinkOracleProxy: proxy implementation mismatch" + "DeployChainlinkOEVWrapper: proxy implementation mismatch" ); require( actualProxyAdmin == address(proxyAdmin), - "DeployChainlinkOracleProxy: proxy admin mismatch" + "DeployChainlinkOEVWrapper: proxy admin mismatch" ); // Validate implementation configuration - ChainlinkOracleProxy proxyInstance = ChainlinkOracleProxy( + ChainlinkOEVWrapper proxyInstance = ChainlinkOEVWrapper( address(proxy) ); require( proxyInstance.owner() == addresses.getAddress("MRD_PROXY_ADMIN"), - "DeployChainlinkOracleProxy: implementation owner mismatch" + "DeployChainlinkOEVWrapper: implementation owner mismatch" ); require( address(proxyInstance.priceFeed()) == addresses.getAddress("CHAINLINK_WELL_USD"), - "DeployChainlinkOracleProxy: price feed address mismatch" + "DeployChainlinkOEVWrapper: price feed address mismatch" ); } function run() public - returns (TransparentUpgradeableProxy, ChainlinkOracleProxy) + returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { Addresses addresses = new Addresses(); ( TransparentUpgradeableProxy proxy, - ChainlinkOracleProxy implementation + 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/templates/CreateMorphoMarket.s.sol b/script/templates/CreateMorphoMarket.s.sol index 94ad80177..3f0d54d5d 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 {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.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"; @@ -240,7 +240,7 @@ contract CreateMorphoMarket is Script, Test { AggregatorV3Interface(addresses.getAddress(ocfg.addressName)); } - ChainlinkOracleProxy logic = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper logic = new ChainlinkOEVWrapper(); ProxyAdmin proxyAdmin = new ProxyAdmin(); TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( @@ -249,9 +249,9 @@ contract CreateMorphoMarket is Script, Test { "" ); - ChainlinkOracleProxy(address(proxy)).initialize( + ChainlinkOEVWrapper(address(proxy)).initialize( addresses.getAddress(ocfg.baseFeedName), - msg.sender, // TODO: should be the Temporal Governor? + addresses.getAddress("TEMPORAL_GOVERNOR"), addresses.getAddress("OEV_FEE_RECIPIENT"), FEE_MULTIPLIER, MAX_ROUND_DELAY, diff --git a/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol b/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol index c4b9c0a09..94c3675e9 100644 --- a/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol @@ -3,20 +3,20 @@ 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 {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.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 {DeployChainlinkOEVWrapper} from "@script/DeployChainlinkOEVWrapper.s.sol"; import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; import {ChainIds, BASE_FORK_ID} from "@utils/ChainIds.sol"; -contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { +contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { using ChainIds for uint256; - ChainlinkOracleProxy public proxy; + ChainlinkOEVWrapper public proxy; AggregatorV3Interface public originalFeed; - DeployChainlinkOracleProxy public deployer; + DeployChainlinkOEVWrapper public deployer; function setUp() public override { uint256 primaryForkId = vm.envUint("PRIMARY_FORK_ID"); @@ -27,7 +27,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { // revertt timestamp back vm.warp(proposalStartTime); - deployer = new DeployChainlinkOracleProxy(); + deployer = new DeployChainlinkOEVWrapper(); originalFeed = AggregatorV3Interface( addresses.getAddress("CHAINLINK_WELL_USD") @@ -35,9 +35,9 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { ( TransparentUpgradeableProxy proxyContract, - ChainlinkOracleProxy implementation + ChainlinkOEVWrapper implementation ) = deployer.deploy(addresses); - proxy = ChainlinkOracleProxy(address(proxyContract)); + proxy = ChainlinkOEVWrapper(address(proxyContract)); // Validate deployment deployer.validate(addresses, proxyContract, implementation); @@ -219,7 +219,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { function testLatestRoundDataRevertsOnZeroPrice() public { MockChainlinkOracle mockFeed = new MockChainlinkOracle(0, 8); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -229,7 +229,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -240,7 +240,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { function testLatestRoundDataRevertsOnNegativePrice() public { MockChainlinkOracle mockFeed = new MockChainlinkOracle(-1, 8); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -250,7 +250,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -262,7 +262,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); mockFeed.set(1, 100e8, 1, 0, 1); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -272,7 +272,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -284,7 +284,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); mockFeed.set(5, 100e8, 1, 1, 4); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -294,7 +294,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -305,7 +305,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { function testGetRoundDataRevertsOnZeroPrice() public { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -315,7 +315,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -328,7 +328,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { function testGetRoundDataRevertsOnZeroUpdatedAt() public { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -338,7 +338,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -351,7 +351,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { function testGetRoundDataRevertsOnStalePrice() public { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -361,7 +361,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -379,7 +379,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { ); mockFeed.set(12345, 100e8, 1, 1, 12345); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -389,7 +389,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); @@ -405,7 +405,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); mockFeed.set(99999, 100e8, 1, 1, 99999); - ChainlinkOracleProxy newProxy = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(newProxy), addresses.getAddress("MRD_PROXY_ADMIN"), @@ -415,7 +415,7 @@ contract ChainlinkOracleProxyIntegrationTest is PostProposalCheck { addresses.getAddress("MRD_PROXY_ADMIN") ) ); - ChainlinkOracleProxy testProxy = ChainlinkOracleProxy( + ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( address(proxyContract) ); diff --git a/test/unit/ChainlinkOracleProxyUnit.t.sol b/test/unit/ChainlinkOracleProxyUnit.t.sol index 2d769cf41..4bf399158 100644 --- a/test/unit/ChainlinkOracleProxyUnit.t.sol +++ b/test/unit/ChainlinkOracleProxyUnit.t.sol @@ -3,11 +3,11 @@ 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 {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; import {MockChainlinkOracle} from "@test/mock/MockChainlinkOracle.sol"; import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOracleWithoutLatestRound.sol"; -contract ChainlinkOracleProxyUnitTest is Test { +contract ChainlinkOEVWrapperUnitTest is Test { address public owner = address(0x1); address public proxyAdmin = address(0x2); @@ -19,7 +19,7 @@ contract ChainlinkOracleProxyUnitTest is Test { ); mockFeed.set(12345, 100e8, 1, 1, 12345); - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(implementation), proxyAdmin, @@ -29,9 +29,7 @@ contract ChainlinkOracleProxyUnitTest is Test { owner ) ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); + ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(address(proxyContract)); // Call latestRound() - should fall back to getting roundId from latestRoundData() uint256 round = proxy.latestRound(); @@ -45,7 +43,7 @@ contract ChainlinkOracleProxyUnitTest is Test { MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); mockFeed.set(99999, 100e8, 1, 1, 99999); - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(implementation), proxyAdmin, @@ -55,9 +53,7 @@ contract ChainlinkOracleProxyUnitTest is Test { owner ) ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); + ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(address(proxyContract)); // Call latestRound() - should use the direct call uint256 round = proxy.latestRound(); @@ -74,7 +70,7 @@ contract ChainlinkOracleProxyUnitTest is Test { ); mockFeed.set(54321, 150e8, 100, 200, 54321); - ChainlinkOracleProxy implementation = new ChainlinkOracleProxy(); + ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( address(implementation), proxyAdmin, @@ -84,9 +80,7 @@ contract ChainlinkOracleProxyUnitTest is Test { owner ) ); - ChainlinkOracleProxy proxy = ChainlinkOracleProxy( - address(proxyContract) - ); + ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(address(proxyContract)); // Get roundId from latestRoundData (uint80 roundId, , , , ) = proxy.latestRoundData(); From 7ee5443c16b441a8f976795056c8ee6f4edf2d46 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Thu, 16 Oct 2025 10:20:26 -0500 Subject: [PATCH 09/31] conf --- script/DeployChainlinkOEVWrapper.s.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/script/DeployChainlinkOEVWrapper.s.sol b/script/DeployChainlinkOEVWrapper.s.sol index cbd818a1b..681e63853 100644 --- a/script/DeployChainlinkOEVWrapper.s.sol +++ b/script/DeployChainlinkOEVWrapper.s.sol @@ -77,9 +77,7 @@ contract DeployChainlinkOEVWrapper is Script { ); // Validate implementation configuration - ChainlinkOEVWrapper proxyInstance = ChainlinkOEVWrapper( - address(proxy) - ); + ChainlinkOEVWrapper proxyInstance = ChainlinkOEVWrapper(address(proxy)); require( proxyInstance.owner() == addresses.getAddress("MRD_PROXY_ADMIN"), From 2fb01624a7589f7859eb47499b3917fa9b2c7edc Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 12:41:51 -0300 Subject: [PATCH 10/31] add ChaininkOEVMorphoWrapper --- src/oracles/ChainlinkOEVMorphoWrapper.sol | 388 ++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 src/oracles/ChainlinkOEVMorphoWrapper.sol diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol new file mode 100644 index 000000000..834e0898a --- /dev/null +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -0,0 +1,388 @@ +// 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"; +/** + * @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 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 + /// @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 The Morpho Blue contract address + IMorphoBlue public morphoBlue; + + /// @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 price is updated early and liquidated + event PriceUpdatedEarlyAndLiquidated( + address indexed sender, + address indexed borrower, + uint256 seizedAssets, + uint256 repaidAssets, + uint256 fee + ); + + /// @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 _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 + * @param _morphoBlue Address of the Morpho Blue contract + */ + function initialize( + address _priceFeed, + address _owner, + address _feeRecipient, + uint16 _feeMultiplier, + uint256 _maxRoundDelay, + uint256 _maxDecrements, + address _morphoBlue + ) public initializer { + require( + _priceFeed != address(0), + "ChainlinkOEVMorphoWrapper: price feed cannot be zero address" + ); + require( + _owner != address(0), + "ChainlinkOEVMorphoWrapper: owner 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" + ); + require( + _morphoBlue != address(0), + "ChainlinkOEVMorphoWrapper: morpho blue cannot be zero address" + ); + __Ownable_init(); + + priceFeed = AggregatorV3Interface(_priceFeed); + cachedRoundId = priceFeed.latestRound(); + maxRoundDelay = _maxRoundDelay; + maxDecrements = _maxDecrements; + morphoBlue = IMorphoBlue(_morphoBlue); + + _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 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 + * @param marketParams The Morpho market parameters identifying the market + * @param borrower The address of the borrower to liquidate + * @param repayAmount The amount of loan tokens to repay on behalf of the borrower + * @param seizedAssets The amount of collateral assets to seize from the borrower + */ + function updatePriceEarlyAndLiquidate( + MarketParams memory marketParams, + address borrower, + uint256 repayAmount, + uint256 seizedAssets + ) external { + // ensure the repay amount is greater than zero + require( + repayAmount > 0, + "ChainlinkOEVMorphoWrapper: repay amount cannot be zero" + ); + + // 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" + ); + + // get the loan token from market params + EIP20Interface loanToken = EIP20Interface(marketParams.loanToken); + + // get the collateral token from market params + EIP20Interface collateralToken = EIP20Interface( + marketParams.collateralToken + ); + + // transfer the repay amount (loan tokens) from caller to this contract + loanToken.transferFrom(msg.sender, address(this), repayAmount); + + // get the latest round data + ( + 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; + + // approve Morpho Blue to spend the loan tokens for liquidation + loanToken.approve(address(morphoBlue), repayAmount); + + // liquidate the borrower on Morpho Blue + // seizedAssets: amount of collateral to seize + // repaidShares: 0 (means we use seizedAssets to determine liquidation amount) + (uint256 actualSeizedAssets, uint256 actualRepaidAssets) = morphoBlue + .liquidate(marketParams, borrower, seizedAssets, 0, ""); + + // calculate the protocol fee based on the seized collateral + uint256 fee = (actualSeizedAssets * uint256(feeMultiplier)) / MAX_BPS; + + // ensure the fee is greater than zero + require(fee > 0, "ChainlinkOEVMorphoWrapper: fee cannot be zero"); + + // if the fee recipient is not set, use the owner as the recipient + address recipient = feeRecipient == address(0) ? owner() : feeRecipient; + + // transfer the protocol fee (in collateral tokens) to the recipient + collateralToken.transfer(recipient, fee); + + // transfer the remaining collateral tokens to the liquidator (caller) + collateralToken.transfer(msg.sender, actualSeizedAssets - fee); + + emit PriceUpdatedEarlyAndLiquidated( + msg.sender, + borrower, + actualSeizedAssets, + actualRepaidAssets, + fee + ); + } +} From ca199b0125eb1847603aa189ddc94863f53878dc Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Thu, 16 Oct 2025 14:38:01 -0300 Subject: [PATCH 11/31] add excess funds logic --- src/morpho/IMorphoBlue.sol | 18 +++++++++ src/oracles/ChainlinkOEVMorphoWrapper.sol | 45 +++++++++++++++-------- 2 files changed, 48 insertions(+), 15 deletions(-) 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 index 834e0898a..7e895429e 100644 --- a/src/oracles/ChainlinkOEVMorphoWrapper.sol +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -298,23 +298,18 @@ contract ChainlinkOEVMorphoWrapper is /** * @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 repayAmount The amount of loan tokens to repay on behalf of the borrower * @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 repayAmount, - uint256 seizedAssets + uint256 seizedAssets, + uint256 maxRepayAmount ) external { - // ensure the repay amount is greater than zero - require( - repayAmount > 0, - "ChainlinkOEVMorphoWrapper: repay amount cannot be zero" - ); - // ensure the borrower is not the zero address require( borrower != address(0), @@ -327,6 +322,12 @@ contract ChainlinkOEVMorphoWrapper is "ChainlinkOEVMorphoWrapper: seized assets cannot be zero" ); + // ensure max repay amount is greater than zero + require( + maxRepayAmount > 0, + "ChainlinkOEVMorphoWrapper: max repay amount cannot be zero" + ); + // get the loan token from market params EIP20Interface loanToken = EIP20Interface(marketParams.loanToken); @@ -335,9 +336,6 @@ contract ChainlinkOEVMorphoWrapper is marketParams.collateralToken ); - // transfer the repay amount (loan tokens) from caller to this contract - loanToken.transferFrom(msg.sender, address(this), repayAmount); - // get the latest round data ( uint80 roundId, @@ -353,15 +351,32 @@ contract ChainlinkOEVMorphoWrapper is // update the cached round id cachedRoundId = roundId; - // approve Morpho Blue to spend the loan tokens for liquidation - loanToken.approve(address(morphoBlue), repayAmount); + // transfer max repay amount from liquidator to this contract + // Morpho will pull the actual amount needed, and we'll return any excess + loanToken.transferFrom(msg.sender, address(this), maxRepayAmount); + + // approve Morpho Blue to spend the loan tokens + loanToken.approve(address(morphoBlue), maxRepayAmount); // liquidate the borrower on Morpho Blue + // Morpho will: 1) Transfer seized collateral to this contract, 2) Pull loan tokens from this contract // seizedAssets: amount of collateral to seize - // repaidShares: 0 (means we use seizedAssets to determine liquidation amount) + // repaidShares: 0 (means we specify collateral amount, Morpho calculates debt repayment) (uint256 actualSeizedAssets, uint256 actualRepaidAssets) = morphoBlue .liquidate(marketParams, borrower, seizedAssets, 0, ""); + // ensure actual repaid amount doesn't exceed liquidator's maximum + 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 protocol fee based on the seized collateral uint256 fee = (actualSeizedAssets * uint256(feeMultiplier)) / MAX_BPS; From e98a412958381a27cdf3cf1e6b948a2e29c0b5e0 Mon Sep 17 00:00:00 2001 From: Ana Bittencourt Date: Fri, 17 Oct 2025 13:56:05 -0300 Subject: [PATCH 12/31] compile --- proposals/mips/mip-o12/mip-o12.sol | 6 +++--- proposals/mips/mip-x14/mip-x14.sol | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) 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 + //); } } From 37ec3f7a366f1945c4b22b4612fb50727cfa5a28 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 11 Nov 2025 11:44:17 -0500 Subject: [PATCH 13/31] wip - updating test but failing due to mip x36 --- proposals/ChainlinkOracleConfigs.sol | 140 ++ proposals/mips/mip-x37/x37.sol | 386 ++++ proposals/mips/mips.json | 7 + script/templates/CreateMorphoMarket.s.sol | 90 +- src/interfaces/IChainlinkOracle.sol | 13 + src/morpho/IMorphoBlue.sol | 18 + src/oracles/ChainlinkOEVMorphoWrapper.sol | 572 ++++++ src/oracles/ChainlinkOEVWrapper.sol | 562 ++++++ ...ChainlinkOEVMorphoWrapperIntegration.t.sol | 435 +++++ .../ChainlinkOEVWrapperIntegration.t.sol | 1694 ++++++++++------- test/unit/ChainlinkOEVWrapperUnit.t.sol | 205 ++ 11 files changed, 3412 insertions(+), 710 deletions(-) create mode 100644 proposals/ChainlinkOracleConfigs.sol create mode 100644 proposals/mips/mip-x37/x37.sol create mode 100644 src/interfaces/IChainlinkOracle.sol create mode 100644 src/oracles/ChainlinkOEVMorphoWrapper.sol create mode 100644 src/oracles/ChainlinkOEVWrapper.sol create mode 100644 test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol create mode 100644 test/unit/ChainlinkOEVWrapperUnit.t.sol diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol new file mode 100644 index 000000000..08c252143 --- /dev/null +++ b/proposals/ChainlinkOracleConfigs.sol @@ -0,0 +1,140 @@ +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., WETH + } + + 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) + string coreMarketAsFeeRecipient; /// e.g.MOONWELL_WELL (the equivalent core maket to call `#_addReserves`) + } + + /// oracle configurations per chain id + mapping(uint256 => OracleConfig[]) internal _oracleConfigs; + + /// morpho market configurations per chain id + mapping(uint256 => MorphoOracleConfig[]) internal _MorphoOracleConfigs; + + constructor() { + /// Initialize oracle configurations for Base + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_ETH_USD", "WETH") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_BTC_USD", "cbBTC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_EURC_USD", "EURC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDS_USD", "USDS") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_TBTC_USD", "TBTC") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_VIRTUAL_USD", "VIRTUAL") + ); + + /// Initialize oracle configurations for Optimism + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_ETH_USD", "WETH") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDC_USD", "USDC") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_DAI_USD", "DAI") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_USDT_USD", "USDT") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_WBTC_USD", "WBTC") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_OP_USD", "OP") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_VELO_USD", "VELO") + ); + _oracleConfigs[OPTIMISM_CHAIN_ID].push( + OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY") + ); + + /// Initialize Morpho market configurations for Base + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig( + "CHAINLINK_WELL_USD", + "CHAINLINK_WELL_USD", + "MOONWELL_WELL" + ) + ); + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig( + "CHAINLINK_MAMO_USD", + "CHAINLINK_MAMO_USD", + "MOONWELL_MAMO" + ) + ); + _MorphoOracleConfigs[BASE_CHAIN_ID].push( + MorphoOracleConfig( + "CHAINLINK_stkWELL_USD", + "CHAINLINK_WELL_USD", + "MOONWELL_WELL" + ) + ); + } + + 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 + }); + } + } + + 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, + coreMarketAsFeeRecipient: _MorphoOracleConfigs[chainId][i] + .coreMarketAsFeeRecipient + }); + } + } + + return configs; + } +} diff --git a/proposals/mips/mip-x37/x37.sol b/proposals/mips/mip-x37/x37.sol new file mode 100644 index 000000000..fc0308ebf --- /dev/null +++ b/proposals/mips/mip-x37/x37.sol @@ -0,0 +1,386 @@ +// 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"; + +// this proposal should +// 1. deploy new non-upgradeable ChainlinkOEVWrapper contracts for core markets +// 2. upgrade existing ChainlinkOEVMorphoWrapper proxy contracts for Morpho markets => test that storage can still be accessed +// 3. call setFeed on the ChainlinkOracle for all core markets, to point to the new ChainlinkOEVWrapper contracts +contract x37 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 = 9900; + 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/MIP-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) + function deploy(Addresses addresses, address) public override { + _deployCoreWrappers(addresses); + _deployMorphoWrappers(addresses); + + vm.selectFork(OPTIMISM_FORK_ID); + _deployCoreWrappers(addresses); + // no morpho markets on optimism + + // switch back + vm.selectFork(BASE_FORK_ID); + } + + // + 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); + + // Validate Base + vm.selectFork(BASE_FORK_ID); + _validateFeedsPointToWrappers(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( + morphoConfigs[i].coreMarketAsFeeRecipient + ), + 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); + console.log("=== Wiring %d core feeds ===", oracleConfigs.length); + + 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); + console.log("Feed %d - Symbol: %s", i, symbol); + console.log(" Wrapper name: %s", wrapperName); + console.log(" Wrapper address: %s", wrapperAddress); + console.log(" Pushed setFeed action to ChainlinkOracle"); + + _pushAction( + chainlinkOracle, + abi.encodeWithSignature( + "setFeed(string,address)", + symbol, + wrapperAddress + ), + string.concat("Set feed to OEV wrapper for ", symbol) + ); + } + console.log("=== Finished wiring core feeds ==="); + } + + /// @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; + } + + console.log( + "Deploying %d core wrappers for chain %d", + oracleConfigs.length, + block.chainid + ); + 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") + ); + + console.log("--- Wrapper %d: %s ---", i, wrapperName); + + ChainlinkOEVWrapper wrapper = new ChainlinkOEVWrapper( + addresses.getAddress(config.oracleName), + addresses.getAddress("TEMPORAL_GOVERNOR"), + addresses.getAddress("CHAINLINK_ORACLE"), + FEE_MULTIPLIER, + MAX_ROUND_DELAY, + MAX_DECREMENTS + ); + console.log("1. Deployed new wrapper at: %s", address(wrapper)); + + // Set existing wrapper to deprecated and add new wrapper + if (addresses.isAddressSet(wrapperName)) { + address oldWrapper = addresses.getAddress(wrapperName); + console.log("2. Old wrapper found at: %s", oldWrapper); + + string memory deprecatedName = string( + abi.encodePacked(wrapperName, "_DEPRECATED") + ); + addresses.addAddress(deprecatedName, oldWrapper); + console.log("3. Set old wrapper as: %s", deprecatedName); + + addresses.changeAddress(wrapperName, address(wrapper), true); + console.log("4. Changed %s to new wrapper", wrapperName); + } else { + addresses.addAddress(wrapperName, address(wrapper)); + console.log("2. Added new wrapper (no previous wrapper)"); + } + } + + vm.stopBroadcast(); + console.log("Finished deploying core wrappers"); + } + + 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(); + } + + // FIX: test the contructor setting + 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 _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"); + 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) + ); + + // priceFeed and morphoBlue wiring preserved + assertEq( + address(wrapper.priceFeed()), + addresses.getAddress(morphoConfigs[i].priceFeedName), + string.concat( + "Morpho wrapper priceFeed mismatch for ", + wrapperName + ) + ); + assertEq( + address(wrapper.morphoBlue()), + morphoBlue, + string.concat( + "Morpho wrapper morphoBlue mismatch for ", + wrapperName + ) + ); + + assertEq( + wrapper.feeMultiplier(), + FEE_MULTIPLIER, + string.concat( + "Morpho wrapper not using expected fee multiplier for ", + wrapperName + ) + ); + + // interface/decimals behavior intact + uint8 d = wrapper.decimals(); + assertEq( + d, + AggregatorV3Interface( + addresses.getAddress(morphoConfigs[i].priceFeedName) + ).decimals(), + string.concat( + "Morpho wrapper decimals mismatch for ", + wrapperName + ) + ); + + // this should be the same as the priceFeed.latestRound() + (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/mips.json b/proposals/mips/mips.json index de63279f9..78ce47e1e 100755 --- a/proposals/mips/mips.json +++ b/proposals/mips/mips.json @@ -1,4 +1,11 @@ [ + { + "envpath": "", + "governor": "MultichainGovernor", + "id": 0, + "path": "mip-x37.sol/x37.json", + "proposalType": "HybridProposal" + }, { "envpath": "proposals/mips/mip-x35/x35.sh", "governor": "MultichainGovernor", diff --git a/script/templates/CreateMorphoMarket.s.sol b/script/templates/CreateMorphoMarket.s.sol index 10fadda24..ac9402ccf 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,59 +255,55 @@ 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) ); } ProxyAdmin proxyAdmin; - if (!addresses.isAddressSet("CHAINLINK_ORACLE_PROXY_ADMIN")) { + if (!addresses.isAddressSet("CHAINLINK_PROXY_ADMIN")) { proxyAdmin = new ProxyAdmin(); - addresses.addAddress( - "CHAINLINK_ORACLE_PROXY_ADMIN", - address(proxyAdmin) + addresses.addAddress("CHAINLINK_PROXY_ADMIN", address(proxyAdmin)); + proxyAdmin.transferOwnership( + addresses.getAddress("TEMPORAL_GOVERNOR") ); } else { proxyAdmin = ProxyAdmin( - addresses.getAddress("CHAINLINK_ORACLE_PROXY_ADMIN") + addresses.getAddress("CHAINLINK_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/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..633ad7009 --- /dev/null +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -0,0 +1,572 @@ +// 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"; +import {MErc20} from "../MErc20.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 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 protocol's core market for this collateral + EIP20Interface(marketParams.collateralToken).approve( + feeRecipient, + protocolFee + ); + MErc20(feeRecipient)._addReserves(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) { + // Get the loan token price from ChainlinkOracle + uint256 loanTokenPrice = _getLoanTokenPrice( + EIP20Interface(marketParams.loanToken) + ); + + // Get the fully adjusted collateral token price + uint256 collateralTokenPrice = _getCollateralTokenPrice( + collateralAnswer, + EIP20Interface(marketParams.collateralToken) + ); + + // Calculate USD value of the repay amount + uint256 repayValueUSD = (repayAmount * loanTokenPrice); + uint256 collateralValueUSD = (collateralReceived * + collateralTokenPrice); + + // Liquidator receives: collateral worth repay amount + bonus (remainder * feeMultiplier) + uint256 liquidatorPaymentUSD = repayValueUSD + + ((collateralValueUSD - repayValueUSD) * uint256(feeMultiplier)) / + MAX_BPS; + + // Convert USD value back to collateral token amount + // Both prices from oracle are already scaled for token decimals, so simple division works + liquidatorFee = liquidatorPaymentUSD / collateralTokenPrice; + + // Protocol gets the remainder + protocolFee = collateralReceived - liquidatorFee; + } +} diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol new file mode 100644 index 000000000..9b4506902 --- /dev/null +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -0,0 +1,562 @@ +// 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 {MErc20} from "../MErc20.sol"; +import {MToken} from "../MToken.sol"; +import {EIP20Interface} from "../EIP20Interface.sol"; +import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; + +import {console} from "forge-std/console.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 The ChainlinkOracle contract + IChainlinkOracle public immutable chainlinkOracle; + + /// @notice The Chainlink price feed this proxy forwards to + AggregatorV3Interface public priceFeed; + + /// @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 + uint256 public maxRoundDelay; + + /// @notice The max decrements + uint256 public maxDecrements; + + /// @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, + 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" + ); + + priceFeed = AggregatorV3Interface(_priceFeed); + feeMultiplier = _feeMultiplier; + cachedRoundId = priceFeed.latestRound(); + maxRoundDelay = _maxRoundDelay; + maxDecrements = _maxDecrements; + chainlinkOracle = IChainlinkOracle(_chainlinkOracle); + + _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 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() + ); + + 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 and redeem collateral + uint256 collateralReceived = _executeLiquidationAndRedeem( + borrower, + repayAmount, + mTokenCollateral, + mTokenLoan, + underlyingLoan, + underlyingCollateral + ); + + console.log("collateralReceived", collateralReceived); + + // Calculate the split of collateral between liquidator and protocol + ( + uint256 liquidatorFee, + uint256 protocolFee + ) = _calculateCollateralSplit( + repayAmount, + collateralAnswer, + collateralReceived, + mTokenLoan, + underlyingCollateral + ); + + // transfer the liquidator's payment (repayment + bonus) to the liquidator + underlyingCollateral.transfer(msg.sender, liquidatorFee); + + // transfer the remainder to the protocol + underlyingCollateral.approve(mTokenCollateral, protocolFee); + MErc20(mTokenCollateral)._addReserves(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); + } + + // 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 Execute liquidation and redeem collateral + /// @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 + /// @param underlyingCollateral The underlying collateral token interface + /// @return collateralReceived The amount of underlying collateral received + function _executeLiquidationAndRedeem( + address borrower, + uint256 repayAmount, + address mTokenCollateral, + address mTokenLoan, + EIP20Interface underlyingLoan, + EIP20Interface underlyingCollateral + ) internal returns (uint256 collateralReceived) { + uint256 collateralBefore = underlyingCollateral.balanceOf( + address(this) + ); + uint256 nativeBalanceBefore = address(this).balance; + + // approve the mToken loan market to spend the loan tokens for liquidation + underlyingLoan.approve(mTokenLoan, repayAmount); + + // liquidate the borrower's position: repay their loan and seize their collateral + uint256 mTokenCollateralBalanceBefore = MTokenInterface( + mTokenCollateral + ).balanceOf(address(this)); + require( + MErc20Interface(mTokenLoan).liquidateBorrow( + borrower, + repayAmount, + MTokenInterface(mTokenCollateral) + ) == 0, + "ChainlinkOEVWrapper: liquidation failed" + ); + + // get the amount of mToken collateral received from liquidation + uint256 mTokenBalanceDelta = MTokenInterface(mTokenCollateral) + .balanceOf(address(this)) - mTokenCollateralBalanceBefore; + + // redeem all the mToken collateral to get the underlying collateral tokens + // Note: mWETH will unwrap to native ETH via WETH_UNWRAPPER + require( + MErc20Interface(mTokenCollateral).redeem(mTokenBalanceDelta) == 0, + "ChainlinkOEVWrapper: redemption failed" + ); + + // If we received native ETH (from mWETH), wrap it back to WETH + uint256 nativeDelta = address(this).balance - nativeBalanceBefore; + if (nativeDelta > 0) { + (bool success, ) = address(underlyingCollateral).call{ + value: nativeDelta + }(abi.encodeWithSignature("deposit()")); + require(success, "ChainlinkOEVWrapper: WETH deposit failed"); + } + + console.log( + "underlyingCollateral.balanceOf(address(this))", + underlyingCollateral.balanceOf(address(this)) + ); + console.log("collateralBefore", collateralBefore); + + collateralReceived = + underlyingCollateral.balanceOf(address(this)) - + collateralBefore; + } + + /// @notice Calculate the split of seized collateral between liquidator and fee recipient + /// @param repayAmount The amount of loan tokens being repaid + /// @param collateralReceived 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 collateralReceived, + address mTokenLoan, + EIP20Interface underlyingCollateral + ) internal view returns (uint256 liquidatorFee, uint256 protocolFee) { + uint256 loanTokenPrice = chainlinkOracle.getUnderlyingPrice( + MToken(mTokenLoan) + ); + + // Get the fully adjusted collateral token price + uint256 collateralTokenPrice = _getCollateralTokenPrice( + collateralAnswer, + underlyingCollateral + ); + + // Calculate USD value of the repay amount + uint256 repayValueUSD = (repayAmount * loanTokenPrice); + uint256 collateralValueUSD = (collateralReceived * + collateralTokenPrice); + + // Liquidator receives: collateral worth repay amount + bonus (remainder * feeMultiplier) + uint256 liquidatorPaymentUSD = repayValueUSD + + ((collateralValueUSD - repayValueUSD) * uint256(feeMultiplier)) / + MAX_BPS; + + // Convert USD value back to collateral token amount + // Both prices from oracle are already scaled for token decimals, so simple division works + liquidatorFee = liquidatorPaymentUSD / collateralTokenPrice; + + // Protocol gets the remainder + protocolFee = collateralReceived - liquidatorFee; + } +} diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol new file mode 100644 index 000000000..749202575 --- /dev/null +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -0,0 +1,435 @@ +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"; + +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; + + // 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); + + // 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, + 50_000e18, // More MAMO tokens + 500e18 + ); + } + + 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, ""); + + // Try to borrow - if it fails due to insufficient liquidity, skip the test + try morpho.borrow(params, borrowAmount, 0, BORROWER, BORROWER) { + // Borrow succeeded, continue with test + } catch { + vm.stopPrank(); + // Market doesn't have liquidity in current fork state, skip test + return; + } + 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 and validate + 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); + + wrapper.updatePriceEarlyAndLiquidate( + params, + BORROWER, + seized, + borrowAmount + ); + vm.stopPrank(); + + assertEq(wrapper.cachedRoundId(), 777); + assertGt( + liqLoanBefore - IERC20(loanToken).balanceOf(LIQUIDATOR), + 0, + "no loan repaid" + ); + assertGt( + IERC20(collToken).balanceOf(LIQUIDATOR) - liqCollBefore, + 0, + "no collateral received" + ); + assertGt( + IERC20(collToken).balanceOf(wrapper.feeRecipient()), + 0, + "no fee collected" + ); + } + + 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..237110493 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,36 @@ 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"; + +contract ChainlinkOEVWrapperIntegrationTest is + PostProposalCheck, + ChainlinkOracleConfigs +{ + event FeeMultiplierChanged( + uint16 oldFeeMultiplier, + uint16 newFeeMultiplier + ); + event PriceUpdatedEarlyAndLiquidated( + address indexed sender, + address indexed borrower, + uint256 repayAmount, + address mTokenCollateral, + address mTokenLoan, + uint256 fee ); - 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; - 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 +50,44 @@ contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { vm.selectFork(primaryForkId); comptroller = Comptroller(addresses.getAddress("UNITROLLER")); marketBase = new MarketBase(comptroller); - - // Deploy a new wrapper for testing - wrapper = ChainlinkFeedOEVWrapper( - addresses.getAddress("CHAINLINK_ETH_USD_OEV_WRAPPER") + // 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)) { + wrappers.push( + ChainlinkOEVWrapper( + payable(addresses.getAddress(wrapperName)) + ) + ); + } + } + } + + 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,792 +113,1097 @@ 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 latestRoundOnChain = wrapper.priceFeed().latestRound(); - 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) - ) - ); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint256(latestRoundOnChain + 1), + mockPrice, + 0, + block.timestamp, + uint256(latestRoundOnChain + 1) + ) + ); - (uint256 roundId, int256 answer, , uint256 timestamp, ) = wrapper - .latestRoundData(); + uint256 mockTimestamp = block.timestamp - 1; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().getRoundData.selector, + uint80(latestRoundOnChain) + ), + abi.encode( + uint80(latestRoundOnChain), + mockPrice, + 0, + mockTimestamp, + uint80(latestRoundOnChain) + ) + ); - 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" - ); + (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; - uint256 expectedTimestamp = block.timestamp; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint256(latestRoundOnChain + 1), + mockPrice, + 0, + block.timestamp, + 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) - ) - ); + vm.warp(block.timestamp + wrapper.maxRoundDelay()); - vm.warp(block.timestamp + wrapper.maxRoundDelay()); + (uint256 roundID, int256 answer, , uint256 timestamp, ) = wrapper + .latestRoundData(); - (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" + ); + } + } - 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 { + 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 testRevertIfInsufficientTax() public { - uint256 tax = 25 gwei * multiplier; - vm.deal(address(this), tax - 1); + function testSetFeeMultiplierRevertNonOwner() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + vm.expectRevert("Ownable: caller is not the owner"); + wrapper.setFeeMultiplier(1); + } + } - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); - vm.expectRevert("ChainlinkOEVWrapper: Insufficient tax"); - wrapper.updatePriceEarly{value: tax - 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 + ) + ); + + ( + 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 testUpdatePriceEarlyOnLiquidationOpportunity() public { - address user = address(0x1234); - // Supply weth - MToken mToken = MToken(addresses.getAddress("MOONWELL_WETH")); - MToken mTokenBorrowed = MToken(addresses.getAddress("MOONWELL_USDC")); + function testAllChainlinkOraclesAreSet() public view { + // Get all markets from the comptroller + MToken[] memory allMarkets = comptroller.getAllMarkets(); - 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(); + // Get the oracle from the comptroller + 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 + string memory symbol = IERC20(underlying).symbol(); + + // Try to get price - this will revert if oracle is not set + uint price = oracle.getUnderlyingPrice(MToken(allMarkets[i])); + + // Price should not be 0 + assertTrue( + price > 0, + string(abi.encodePacked("Oracle not set for ", symbol)) + ); } + } - uint256 borrowAmount; - { - // Calculate maximum borrow amount - (, uint256 liquidity, ) = comptroller.getAccountLiquidity(user); + 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())); + + 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 + ) + ); - // 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 + vm.expectRevert("Chainlink price cannot be lower or equal to 0"); + wrapper.latestRoundData(); + } + } - // Ensure sufficient borrow cap using the utility function - marketBase.ensureSufficientBorrowCap( - mTokenBorrowed, - borrowAmount, - addresses + 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)) ); - // make sure the mToken has enough underlying to borrow - deal( - MErc20(address(mTokenBorrowed)).underlying(), - address(mTokenBorrowed), - borrowAmount + 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 + ) ); - vm.warp(block.timestamp + 1 days); - vm.prank(user); - uint256 err = MErc20(address(mTokenBorrowed)).borrow(borrowAmount); - assertEq(err, 0, "Borrow failed"); + vm.expectRevert("Round is in incompleted state"); + wrapper.latestRoundData(); } + } - { - (, int256 priceBefore, , , ) = wrapper.latestRoundData(); - int256 newPrice = (priceBefore * 70) / 100; // 30% drop + 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 tax = (50 gwei - 25 gwei) * - uint256(wrapper.feeMultiplier()); - vm.deal(address(this), tax); - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); + 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 + ) + ); + + vm.expectRevert("Stale price"); + wrapper.latestRoundData(); + } + } + function testNoUpdateEarlyReturnsPreviousRound() public { + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector + wrapper.priceFeed().latestRound.selector + ), + abi.encode(uint256(2)) + ); + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector ), abi.encode( - uint256(latestRoundOnChain + 1), - newPrice, - 0, - block.timestamp, - uint256(latestRoundOnChain + 1) + uint80(2), // roundId + int256(3_000e8), // answer + uint256(0), // startedAt + uint256(block.timestamp), // updatedAt + uint80(3) // answeredInRound ) ); - wrapper.updatePriceEarly{value: tax}(); vm.mockCall( - address(wrapper.originalFeed()), + address(wrapper.priceFeed()), abi.encodeWithSelector( - wrapper.originalFeed().getRoundData.selector, - uint80(latestRoundOnChain + 1) + wrapper.priceFeed().getRoundData.selector ), abi.encode( - uint80(latestRoundOnChain + 1), - newPrice, - 0, - block.timestamp, - uint80(latestRoundOnChain + 1) + 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" + ); + } + } + + function testMaxDecrementsLimit() public { + // Mock the feed to return valid data for specific rounds + uint256 latestRound = 100; + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; + // Mock valid price data for round 100 (latest) 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( + 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" ); - (uint256 err, uint256 liquidity, uint256 shortfall) = comptroller - .getHypotheticalAccountLiquidity(user, address(mToken), 0, 0); + // 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) + ) + ); - assertEq(err, 0, "Error in hypothetical liquidity calculation"); - assertEq(liquidity, 0, "Liquidity should be 0"); - assertGt(shortfall, 0, "Position should be underwater"); + // 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" + ); } + } - // Setup liquidator - address liquidator = address(0x5678); - uint256 repayAmount = borrowAmount / 4; + /** updatePriceEarlyAndLiquidate */ - deal( - MErc20(address(mTokenBorrowed)).underlying(), - liquidator, - repayAmount + function testUpdatePriceEarlyAndLiquidate_Succeeds() public { + OracleConfig[] memory oracleConfigs = getOracleConfigurations( + block.chainid ); - // 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(); - } + for (uint256 i = 0; i < wrappers.length; i++) { + ChainlinkOEVWrapper wrapper = wrappers[i]; - function testSetFeeMultiplier() public { - uint8 newMultiplier = 1; + // Get the collateral mToken + string memory mTokenKey = string( + abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) + ); + if (!addresses.isAddressSet(mTokenKey)) continue; - uint8 originalMultiplier = wrapper.feeMultiplier(); - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit FeeMultiplierChanged(originalMultiplier, newMultiplier); - wrapper.setFeeMultiplier(newMultiplier); + address mTokenCollateralAddr = addresses.getAddress(mTokenKey); - assertEq( - wrapper.feeMultiplier(), - newMultiplier, - "Fee multiplier not updated" - ); - } + // Use USDC as borrow token + if ( + !addresses.isAddressSet("MOONWELL_USDC") || + addresses.getAddress("MOONWELL_USDC") == mTokenCollateralAddr + ) { + continue; + } + address mTokenBorrowAddr = addresses.getAddress("MOONWELL_USDC"); + + // Calculate amounts based on price and collateral factor + uint256 collateralAmount; + uint256 borrowAmount; + { + (, int256 currentPrice, , , ) = wrapper.latestRoundData(); + require(currentPrice > 0, "invalid price"); + + address underlying = MErc20(mTokenCollateralAddr).underlying(); + (bool success, bytes memory data) = underlying.staticcall( + abi.encodeWithSignature("decimals()") + ); + require(success && data.length >= 32, "decimals() call failed"); + uint8 decimals = abi.decode(data, (uint8)); + + // Get the actual collateral factor for this market + (bool isListed, uint256 collateralFactorMantissa) = comptroller + .markets(mTokenCollateralAddr); + require(isListed, "market not listed"); + // collateralFactorMantissa is scaled by 1e18 (e.g., 0.65e18 = 65%) + uint256 collateralFactorBps = (collateralFactorMantissa * + 10000) / 1e18; // Convert to basis points + + // $10k worth of collateral + collateralAmount = + (10_000 * 10 ** decimals * 1e8) / + uint256(currentPrice); + + // Borrow at 80% of the collateral factor (safe margin) + // maxBorrow = $10k * CF, actualBorrow = maxBorrow * 0.8 + borrowAmount = + ((10_000 * collateralFactorBps * 80) / (10000 * 100)) * + 1e6; // Result in USDC (6 decimals) + } - function testSetFeeMultiplierRevertNonOwner() public { - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setFeeMultiplier(1); - } + address borrower = _borrower(wrapper); - function testSetMaxRoundDelay() public { - uint8 newWindow = 3; + // 1) Deposit collateral + { + MToken mToken = MToken(mTokenCollateralAddr); + if (block.timestamp <= mToken.accrualBlockTimestamp()) { + vm.warp(mToken.accrualBlockTimestamp() + 1); + } - uint8 originalWindow = wrapper.maxRoundDelay(); - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit NewMaxRoundDelay(originalWindow, newWindow); - wrapper.setMaxRoundDelay(newWindow); + // Check and increase supply cap if needed + uint256 supplyCap = comptroller.supplyCaps( + mTokenCollateralAddr + ); + if (supplyCap != 0) { + 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(); + } + } - assertEq( - wrapper.maxRoundDelay(), - newWindow, - "Max round delay not updated" - ); - } + _mintMToken(borrower, mTokenCollateralAddr, collateralAmount); - function testmaxRoundDelayRevertNonOwner() public { - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setMaxRoundDelay(10); - } + address[] memory markets = new address[](2); + markets[0] = mTokenCollateralAddr; + markets[1] = mTokenBorrowAddr; + vm.prank(borrower); + comptroller.enterMarkets(markets); + } - function testGetRoundData() public { - uint80 roundId = 1; - int256 mockPrice = 3_000e8; - uint256 mockTimestamp = block.timestamp; + // 2) Borrow USDC + { + MToken mToken = MToken(mTokenBorrowAddr); + if (block.timestamp <= mToken.accrualBlockTimestamp()) { + vm.warp(mToken.accrualBlockTimestamp() + 1); + } - // 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) - ); + uint256 cap = comptroller.borrowCaps(mTokenBorrowAddr); + uint256 total = mToken.totalBorrows(); + + if (cap != 0 && 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(); + } - ( - 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" - ); - } + vm.prank(borrower); + assertEq( + MErc20Delegator(payable(mTokenBorrowAddr)).borrow( + borrowAmount + ), + 0, + string( + abi.encodePacked( + "borrow failed for ", + oracleConfigs[i].symbol + ) + ) + ); + } - 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(); + // 3) Crash price 60% to make position underwater + { + (, int256 price, , , ) = wrapper.latestRoundData(); + int256 crashedPrice = (price * 40) / 100; + + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(777), + crashedPrice, + uint256(0), + block.timestamp, + uint80(777) + ) + ); - uint256 tax = 25 gwei * multiplier; - vm.deal(address(this), tax); + (uint256 err, , uint256 shortfall) = comptroller + .getAccountLiquidity(borrower); + require(err == 0 && shortfall > 0, "position not underwater"); + } - vm.txGasPrice(50 gwei); - vm.fee(25 gwei); + // 4) Execute liquidation + { + address liquidator = _liquidator(wrapper); + uint256 repayAmount = borrowAmount / 10; + address borrowUnderlying = MErc20(mTokenBorrowAddr) + .underlying(); + deal(borrowUnderlying, liquidator, repayAmount); + + vm.startPrank(liquidator); + IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); + + uint256 borrowBefore = MErc20(mTokenBorrowAddr) + .borrowBalanceStored(borrower); + uint256 collateralBefore = MErc20(mTokenCollateralAddr) + .balanceOf(borrower); + uint256 reservesBefore = MErc20(mTokenCollateralAddr) + .totalReserves(); + + if ( + block.timestamp <= + MToken(mTokenBorrowAddr).accrualBlockTimestamp() + ) { + vm.warp( + MToken(mTokenBorrowAddr).accrualBlockTimestamp() + 1 + ); + } - 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}(); + wrapper.updatePriceEarlyAndLiquidate( + borrower, + repayAmount, + mTokenCollateralAddr, + mTokenBorrowAddr + ); + vm.stopPrank(); - uint256 totalReservesAfter = MErc20( - addresses.getAddress("MOONWELL_WETH") - ).totalReserves(); + // 5) Validate results + assertEq(wrapper.cachedRoundId(), 777); + assertLt( + MErc20(mTokenBorrowAddr).borrowBalanceStored(borrower), + borrowBefore + ); + assertLt( + MErc20(mTokenCollateralAddr).balanceOf(borrower), + collateralBefore + ); - 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" - ); + address collateralUnderlying = MErc20(mTokenCollateralAddr) + .underlying(); + assertGt(IERC20(collateralUnderlying).balanceOf(liquidator), 0); + // Protocol fee is now added to mToken reserves + assertGt( + MErc20(mTokenCollateralAddr).totalReserves(), + reservesBefore + ); + } + } } - function testAllChainlinkOraclesAreSet() public view { - // Get all markets from the comptroller - MToken[] memory allMarkets = comptroller.getAllMarkets(); + function testUpdatePriceEarlyAndLiquidate_RevertZeroRepay() 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(0xBEEF), + 0, + mTokenAddr, + mTokenAddr + ); + } + } - // Get the oracle from the comptroller - ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); + 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 + ); + } + } - for (uint i = 0; i < allMarkets.length; i++) { - address underlying = MErc20(address(allMarkets[i])).underlying(); + 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) + ); + } + } - // Get token symbol - string memory symbol = IERC20(underlying).symbol(); + 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 + ); + } + } - // Try to get price - this will revert if oracle is not set - uint price = oracle.getUnderlyingPrice(MToken(allMarkets[i])); + 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 + ); + } + } - // Price should not be 0 - assertTrue( - price > 0, - string(abi.encodePacked("Oracle not set for ", symbol)) + 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 testMultipleAccountHealthChecks() public { - MToken[] memory allMarkets = comptroller.getAllMarkets(); + function testUpdatePriceEarlyAndLiquidate_RevertStalePrice() 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; + } + // 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 + ); + } + } - // Test 10 different accounts - for (uint accountId = 0; accountId < 10; accountId++) { - address account = address(uint160(0x1000 + accountId)); + 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; - // first enter all markets - address[] memory markets = new address[](allMarkets.length); - for (uint i = 0; i < allMarkets.length; i++) { - markets[i] = address(allMarkets[i]); + // 1) Deposit WETH as collateral + { + uint256 accrualTsPre = mTokenCollateral.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); } - 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(); + uint256 supplyAmount = 1 ether; + _mintMToken(borrower, address(mTokenCollateral), supplyAmount); - // check max mint allowed - uint256 maxMint = marketBase.getMaxSupplyAmount(mToken); + address[] memory markets = new address[](2); + markets[0] = address(mTokenCollateral); + markets[1] = address(mTokenBorrow); + vm.prank(borrower); + comptroller.enterMarkets(markets); - if (maxMint == 0) { - continue; - } + assertTrue( + comptroller.checkMembership(borrower, mTokenCollateral), + "not in collateral market" + ); + } - // Mint different amounts based on account and market - uint256 amount = 1000 * - (accountId + 1) * - (marketId + 1) * - (10 ** IERC20(underlying).decimals()); + // 2) Borrow USDC against WETH collateral (but stay healthy) + { + uint256 accrualTsPre = mTokenBorrow.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); + } - if (amount > maxMint) { - amount = maxMint; - } + // Borrow only 1,000 USDC (well below 80% LTV, so position stays healthy) + borrowAmount = 1_000 * 1e6; - _mintMToken(account, address(mToken), amount); + 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(); } - // 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.prank(borrower); + assertEq( + MErc20Delegator(payable(address(mTokenBorrow))).borrow( + borrowAmount + ), + 0, + "borrow failed" + ); + } - // Test hypothetical liquidity for each asset - for (uint marketId = 0; marketId < allMarkets.length; marketId++) { - MToken mToken = allMarkets[marketId]; + // 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"); + } - uint256 mTokenBalance = mToken.balanceOf(account); - if (mTokenBalance == 0) { - continue; - } + // 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); - uint redeemAmount = mTokenBalance / 2; + vm.startPrank(liquidator); + IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); - (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" - ); + 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 testLatestRoundDataRevertOnChainlinkPriceIsZero() public { - uint256 timestampBefore = vm.getBlockTimestamp(); - vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); - - 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 - ) - ); - - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - wrapper.latestRoundData(); - } - - function testLatestRoundDataRevertOnIncompleteRoundState() public { - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector(wrapper.originalFeed().latestRound.selector), - abi.encode(uint256(1)) - ); - - 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 - ) + function testUpdatePriceEarlyAndLiquidate_RevertRedemptionFailed() 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; - vm.expectRevert("Round is in incompleted state"); - wrapper.latestRoundData(); - } + // 1) Deposit WETH as collateral + { + uint256 accrualTsPre = mTokenCollateral.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); + } - function testLatestRoundDataRevertOnStalePriceData() public { - uint256 timestampBefore = vm.getBlockTimestamp(); - vm.warp(timestampBefore + uint256(wrapper.maxRoundDelay())); + uint256 supplyAmount = 1 ether; + _mintMToken(borrower, address(mTokenCollateral), supplyAmount); - 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 - ) - ); + address[] memory markets = new address[](2); + markets[0] = address(mTokenCollateral); + markets[1] = address(mTokenBorrow); + vm.prank(borrower); + comptroller.enterMarkets(markets); - vm.expectRevert("Stale price"); - wrapper.latestRoundData(); - } + assertTrue( + comptroller.checkMembership(borrower, mTokenCollateral), + "not in collateral market" + ); + } - 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 - ) - ); - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().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" - ); - } + // 2) Borrow USDC against WETH collateral + { + uint256 accrualTsPre = mTokenBorrow.accrualBlockTimestamp(); + if (block.timestamp <= accrualTsPre) { + vm.warp(accrualTsPre + 1); + } - function testSetMaxDecrements() public { - uint8 newMaxDecrements = 15; - uint8 originalMaxDecrements = wrapper.maxDecrements(); + borrowAmount = 2_600 * 1e6; // 2,600 USDC - // Non-owner should not be able to change maxDecrements - vm.prank(address(0x1234)); - vm.expectRevert("Ownable: caller is not the owner"); - wrapper.setMaxDecrements(newMaxDecrements); + 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(); + } - // Owner should be able to change maxDecrements - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - vm.expectEmit(address(wrapper)); - emit MaxDecrementsChanged(originalMaxDecrements, newMaxDecrements); - wrapper.setMaxDecrements(newMaxDecrements); + vm.prank(borrower); + assertEq( + MErc20Delegator(payable(address(mTokenBorrow))).borrow( + borrowAmount + ), + 0, + "borrow failed" + ); + } - assertEq( - wrapper.maxDecrements(), - newMaxDecrements, - "maxDecrements should be updated" - ); - assertNotEq( - wrapper.maxDecrements(), - originalMaxDecrements, - "maxDecrements should be different from original" - ); - } + // 3) Force position underwater by crashing ETH price to $2,000 + { + int256 crashedPrice = 2_000 * 1e8; - function testMaxDecrementsLimit() public { - // Mock the feed to return valid data for specific rounds - uint256 latestRound = 100; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(777), + crashedPrice, + uint256(0), + block.timestamp, + uint80(777) + ) + ); - // Set maxDecrements to 3 (shouldn't reach round 95) - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - wrapper.setMaxDecrements(3); + (uint256 err, uint256 liq, uint256 shortfall) = comptroller + .getAccountLiquidity(borrower); + assertEq(err, 0, "liquidity error"); + assertEq(liq, 0, "expected no liquidity"); + assertGt(shortfall, 0, "no shortfall created"); + } - // Mock valid price data for round 100 (latest) - vm.mockCall( - address(wrapper.originalFeed()), - abi.encodeWithSelector( - wrapper.originalFeed().latestRoundData.selector - ), - abi.encode( - uint80(latestRound), - int256(1000), - uint256(block.timestamp), - uint256(block.timestamp), - uint80(latestRound) - ) - ); + // 4) Mock redeem to fail, then attempt liquidation + { + address liquidator = _liquidator(wrapper); + uint256 repayAmount = borrowAmount / 10; // 260 USDC + address borrowUnderlying = MErc20(address(mTokenBorrow)) + .underlying(); + deal(borrowUnderlying, liquidator, repayAmount); - // 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" - ); - assertEq(roundId, uint80(latestRound), "Should return latest round ID"); - assertEq( - answeredInRound, - uint80(latestRound), - "Should return latest answered round" - ); + vm.startPrank(liquidator); + IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); - // Set maxDecrements to 6 (should reach round 95) - vm.prank(addresses.getAddress("TEMPORAL_GOVERNOR")); - wrapper.setMaxDecrements(6); + if (block.timestamp <= mTokenBorrow.accrualBlockTimestamp()) { + vm.warp(mTokenBorrow.accrualBlockTimestamp() + 1); + } - // 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) - ) - ); + // Mock redeem to fail (return error code 1) + vm.mockCall( + address(mTokenCollateral), + abi.encodeWithSelector( + MErc20Delegator(payable(address(mTokenCollateral))) + .redeem + .selector + ), + abi.encode(uint256(1)) + ); - // 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" - ); + vm.expectRevert(bytes("ChainlinkOEVWrapper: redemption failed")); + wrapper.updatePriceEarlyAndLiquidate( + borrower, + repayAmount, + address(mTokenCollateral), + address(mTokenBorrow) + ); + vm.stopPrank(); + } } - 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)) - ); - - // Set gas price higher than base fee to avoid underflow - vm.fee(1 gwei); - vm.txGasPrice(2 gwei); - - // Calculate required payment - uint256 payment = (tx.gasprice - block.basefee) * - uint256(wrapper.feeMultiplier()); - - vm.deal(address(this), payment); - + 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( - latestRoundOnChain + 1, - 300e8, - 0, - block.timestamp, - latestRoundOnChain + 1 - ) + abi.encode(roundId_, price_, uint256(0), block.timestamp, roundId_) ); - vm.expectRevert("ChainlinkOEVWrapper: Failed to add reserves"); - wrapper.updatePriceEarly{value: payment}(); } } diff --git a/test/unit/ChainlinkOEVWrapperUnit.t.sol b/test/unit/ChainlinkOEVWrapperUnit.t.sol new file mode 100644 index 000000000..60ffeb608 --- /dev/null +++ b/test/unit/ChainlinkOEVWrapperUnit.t.sol @@ -0,0 +1,205 @@ +// 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); + + 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, + 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, + 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); + } +} From 1a514d245e0890a752f84782fd71d5f58dc4539b Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 11 Nov 2025 12:03:56 -0500 Subject: [PATCH 14/31] deprecated oracle proxy stuff --- script/DeployChainlinkOEVWrapper.s.sol | 72 +-- .../ChainlinkOracleProxyIntegration.t.sol | 428 ------------------ test/unit/ChainlinkOracleProxyUnit.t.sol | 98 ---- 3 files changed, 5 insertions(+), 593 deletions(-) delete mode 100644 test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol delete mode 100644 test/unit/ChainlinkOracleProxyUnit.t.sol diff --git a/script/DeployChainlinkOEVWrapper.s.sol b/script/DeployChainlinkOEVWrapper.s.sol index 681e63853..0387ff50e 100644 --- a/script/DeployChainlinkOEVWrapper.s.sol +++ b/script/DeployChainlinkOEVWrapper.s.sol @@ -14,38 +14,11 @@ contract DeployChainlinkOEVWrapper is Script { function deploy( Addresses addresses ) public returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { - vm.startBroadcast(); - - // Deploy the implementation contract - ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); - - // Get the ProxyAdmin address - address proxyAdmin = addresses.getAddress("MRD_PROXY_ADMIN"); - - // Prepare initialization data - bytes memory initData = abi.encodeWithSelector( - ChainlinkOEVWrapper.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) + // no longer compatible with old wrapper + return ( + TransparentUpgradeableProxy(payable(address(0))), + ChainlinkOEVWrapper(payable(address(0))) ); - addresses.addAddress("CHAINLINK_ORACLE_PROXY", address(proxy)); - - return (proxy, implementation); } function validate( @@ -53,42 +26,7 @@ contract DeployChainlinkOEVWrapper is Script { TransparentUpgradeableProxy proxy, ChainlinkOEVWrapper 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), - "DeployChainlinkOEVWrapper: proxy implementation mismatch" - ); - - require( - actualProxyAdmin == address(proxyAdmin), - "DeployChainlinkOEVWrapper: proxy admin mismatch" - ); - - // Validate implementation configuration - ChainlinkOEVWrapper proxyInstance = ChainlinkOEVWrapper(address(proxy)); - - require( - proxyInstance.owner() == addresses.getAddress("MRD_PROXY_ADMIN"), - "DeployChainlinkOEVWrapper: implementation owner mismatch" - ); - - require( - address(proxyInstance.priceFeed()) == - addresses.getAddress("CHAINLINK_WELL_USD"), - "DeployChainlinkOEVWrapper: price feed address mismatch" - ); + // no longer compatible with old wrapper } function run() diff --git a/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol b/test/integration/oracle/ChainlinkOracleProxyIntegration.t.sol deleted file mode 100644 index 94c3675e9..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 {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; -import {AggregatorV3Interface} from "@protocol/oracles/AggregatorV3Interface.sol"; -import {MockChainlinkOracle} from "@test/mock/MockChainlinkOracle.sol"; -import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOracleWithoutLatestRound.sol"; -import {DeployChainlinkOEVWrapper} from "@script/DeployChainlinkOEVWrapper.s.sol"; -import {PostProposalCheck} from "@test/integration/PostProposalCheck.sol"; -import {ChainIds, BASE_FORK_ID} from "@utils/ChainIds.sol"; - -contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck { - using ChainIds for uint256; - - ChainlinkOEVWrapper public proxy; - AggregatorV3Interface public originalFeed; - DeployChainlinkOEVWrapper 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 DeployChainlinkOEVWrapper(); - - originalFeed = AggregatorV3Interface( - addresses.getAddress("CHAINLINK_WELL_USD") - ); - - ( - TransparentUpgradeableProxy proxyContract, - ChainlinkOEVWrapper implementation - ) = deployer.deploy(addresses); - proxy = ChainlinkOEVWrapper(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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - address(proxyContract) - ); - - vm.expectRevert("Chainlink price cannot be lower or equal to 0"); - testProxy.latestRoundData(); - } - - function testLatestRoundDataRevertsOnNegativePrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(-1, 8); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - address(proxyContract) - ); - - vm.expectRevert("Stale price"); - testProxy.latestRoundData(); - } - - function testGetRoundDataRevertsOnZeroPrice() public { - MockChainlinkOracle mockFeed = new MockChainlinkOracle(100e8, 8); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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); - - ChainlinkOEVWrapper newProxy = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(newProxy), - addresses.getAddress("MRD_PROXY_ADMIN"), - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - addresses.getAddress("MRD_PROXY_ADMIN") - ) - ); - ChainlinkOEVWrapper testProxy = ChainlinkOEVWrapper( - 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/ChainlinkOracleProxyUnit.t.sol b/test/unit/ChainlinkOracleProxyUnit.t.sol deleted file mode 100644 index 4bf399158..000000000 --- a/test/unit/ChainlinkOracleProxyUnit.t.sol +++ /dev/null @@ -1,98 +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 {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 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); - - ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(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); - - ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(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); - - ChainlinkOEVWrapper implementation = new ChainlinkOEVWrapper(); - TransparentUpgradeableProxy proxyContract = new TransparentUpgradeableProxy( - address(implementation), - proxyAdmin, - abi.encodeWithSignature( - "initialize(address,address)", - address(mockFeed), - owner - ) - ); - ChainlinkOEVWrapper proxy = ChainlinkOEVWrapper(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" - ); - } -} From a31d959eca4fa4506f24de4a69c65bd26739dd4d Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 11 Nov 2025 12:07:34 -0500 Subject: [PATCH 15/31] more formatting? --- CLAUDE.md | 3 +- chains/10.json | 2 +- proposals/mips/mip-x35/x35.md | 40 +++---- proposals/mips/mip-x36/mip-x36.sol | 107 +++++++++++------- proposals/mips/mip-x36/x36.md | 57 +++++++--- .../CrossChainPublishMessageIntegration.t.sol | 1 - 6 files changed, 126 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b3651e298..ee2dcc13f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ -- Always set the id in @proposals/mips/mips.json to 0 when creating new proposals \ No newline at end of file +- Always set the id in @proposals/mips/mips.json to 0 when creating new + proposals diff --git a/chains/10.json b/chains/10.json index ad496e27f..df7de0bb6 100644 --- a/chains/10.json +++ b/chains/10.json @@ -719,7 +719,7 @@ "isContract": true, "name": "CHAINLINK_wrsETH_ETH_EXCHANGE_RATE" }, - { + { "addr": "0x5fddda4866db63685018faa1bfc9bfce7072014c", "isContract": true, "name": "CHAINLINK_wrsETH_COMPOSITE_ORACLE" diff --git a/proposals/mips/mip-x35/x35.md b/proposals/mips/mip-x35/x35.md index d72b50c51..907224331 100644 --- a/proposals/mips/mip-x35/x35.md +++ b/proposals/mips/mip-x35/x35.md @@ -8,11 +8,11 @@ A proposal to adjust the following risk parameters: -| Parameters | Current Value | Recommended Value | -| --- | --- | --- | -| MAMO Borrow Cap | 12M | 3M | -| tBTC Collateral Factor | 85% | 84% | -| LBTC Collateral Factor | 85% | 84% | +| Parameters | Current Value | Recommended Value | +| ---------------------- | ------------- | ----------------- | +| MAMO Borrow Cap | 12M | 3M | +| tBTC Collateral Factor | 85% | 84% | +| LBTC Collateral Factor | 85% | 84% | \*Cap changes will be implemented via Cap Guardian @@ -21,11 +21,11 @@ A proposal to adjust the following risk parameters: A proposal to adjust the IR Parameters for USDC: | **USDC IR Parameters** | Current Value | Recommended Value | -| --- | --- | --- | -| Base | 0 | 0 | -| Kink | 0.9 | 0.9 | -| Multiplier | **0.072** | **0.08** | -| Jump Multiplier | 9 | 9 | +| ---------------------- | ------------- | ----------------- | +| Base | 0 | 0 | +| Kink | 0.9 | 0.9 | +| Multiplier | **0.072** | **0.08** | +| Jump Multiplier | 9 | 9 | ## OP Mainnet @@ -36,18 +36,18 @@ A proposal to adjust the IR Parameters for USDC: A proposal to adjust the IR Parameters for USDT0 and DAI: | **USDT0 IR Parameters** | Current Value | Recommended Value | -| --- | --- | --- | -| Base | 0 | 0 | -| Kink | 0.9 | 0.9 | -| Multiplier | **0.061** | **0.067** | -| Jump Multiplier | 9 | 9 | +| ----------------------- | ------------- | ----------------- | +| Base | 0 | 0 | +| Kink | 0.9 | 0.9 | +| Multiplier | **0.061** | **0.067** | +| Jump Multiplier | 9 | 9 | | **DAI IR Parameters** | Current Value | Recommended Value | -| --- | --- | --- | -| Base | 0 | 0 | -| Kink | 0.9 | 0.9 | -| Multiplier | **0.061** | **0.067** | -| Jump Multiplier | 9 | 9 | +| --------------------- | ------------- | ----------------- | +| Base | 0 | 0 | +| Kink | 0.9 | 0.9 | +| Multiplier | **0.061** | **0.067** | +| Jump Multiplier | 9 | 9 | For more information refer to our forum post: [link](https://forum.moonwell.fi/t/anthias-labs-risk-parameter-recommendations/1759/6). diff --git a/proposals/mips/mip-x36/mip-x36.sol b/proposals/mips/mip-x36/mip-x36.sol index a59605d11..ec04dbc72 100644 --- a/proposals/mips/mip-x36/mip-x36.sol +++ b/proposals/mips/mip-x36/mip-x36.sol @@ -42,7 +42,7 @@ contract mipx36 is HybridProposal { _setProposalDescription(proposalDescription); } - function run() public override { + function run() public override { primaryForkId().createForksAndSelect(); Addresses addresses = new Addresses(); @@ -72,7 +72,6 @@ contract mipx36 is HybridProposal { } } - function primaryForkId() public pure override returns (uint256) { return BASE_FORK_ID; } @@ -81,45 +80,61 @@ contract mipx36 is HybridProposal { // Deploy new ChainlinkCompositeOracle for Base wrsETH vm.selectFork(BASE_FORK_ID); - if(!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { - vm.startBroadcast(); + if (!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { + vm.startBroadcast(); - address baseEthUsdFeed = addresses.getAddress("CHAINLINK_ETH_USD"); - address baseWrsethEthExchangeRateFeed = addresses.getAddress("CHAINLINK_wrsETH_ETH_EXCHANGE_RATE"); + address baseEthUsdFeed = addresses.getAddress("CHAINLINK_ETH_USD"); + address baseWrsethEthExchangeRateFeed = addresses.getAddress( + "CHAINLINK_wrsETH_ETH_EXCHANGE_RATE" + ); - baseWrsethOracle = new ChainlinkCompositeOracle( - baseEthUsdFeed, - baseWrsethEthExchangeRateFeed, - address(0) - ); + baseWrsethOracle = new ChainlinkCompositeOracle( + baseEthUsdFeed, + baseWrsethEthExchangeRateFeed, + address(0) + ); - vm.stopBroadcast(); + vm.stopBroadcast(); - addresses.addAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE", address(baseWrsethOracle)); + addresses.addAddress( + "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + address(baseWrsethOracle) + ); } else { - baseWrsethOracle = ChainlinkCompositeOracle(addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE")); + baseWrsethOracle = ChainlinkCompositeOracle( + addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE") + ); } // Deploy new ChainlinkCompositeOracle for Optimism wrsETH vm.selectFork(OPTIMISM_FORK_ID); - if(!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { - vm.startBroadcast(); + if (!addresses.isAddressSet("CHAINLINK_wrsETH_COMPOSITE_ORACLE")) { + vm.startBroadcast(); - address optimismEthUsdFeed = addresses.getAddress("CHAINLINK_ETH_USD"); - address optimismWrsethEthExchangeRateFeed = addresses.getAddress("CHAINLINK_wrsETH_ETH_EXCHANGE_RATE"); + address optimismEthUsdFeed = addresses.getAddress( + "CHAINLINK_ETH_USD" + ); + address optimismWrsethEthExchangeRateFeed = addresses.getAddress( + "CHAINLINK_wrsETH_ETH_EXCHANGE_RATE" + ); - optimismWrsethOracle = new ChainlinkCompositeOracle( - optimismEthUsdFeed, - optimismWrsethEthExchangeRateFeed, - address(0) - ); + optimismWrsethOracle = new ChainlinkCompositeOracle( + optimismEthUsdFeed, + optimismWrsethEthExchangeRateFeed, + address(0) + ); - vm.stopBroadcast(); + vm.stopBroadcast(); - addresses.addAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE", address(optimismWrsethOracle)); + addresses.addAddress( + "CHAINLINK_wrsETH_COMPOSITE_ORACLE", + address(optimismWrsethOracle) + ); } else { - optimismWrsethOracle = ChainlinkCompositeOracle(addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE")); + optimismWrsethOracle = ChainlinkCompositeOracle( + addresses.getAddress("CHAINLINK_wrsETH_COMPOSITE_ORACLE") + ); } } @@ -198,7 +213,9 @@ contract mipx36 is HybridProposal { ); // Update oracle price feed on Optimism - address optimismChainlinkOracle = addresses.getAddress("CHAINLINK_ORACLE"); + address optimismChainlinkOracle = addresses.getAddress( + "CHAINLINK_ORACLE" + ); _pushAction( optimismChainlinkOracle, abi.encodeWithSignature( @@ -213,10 +230,7 @@ contract mipx36 is HybridProposal { function teardown(Addresses addresses, address) public pure override {} - function _testMintPaused( - address mToken, - address underlying - ) internal { + function _testMintPaused(address mToken, address underlying) internal { MErc20Delegator mTokenDelegator = MErc20Delegator(payable(mToken)); uint256 mintAmount = 1e18; @@ -233,7 +247,9 @@ contract mipx36 is HybridProposal { // ============ VALIDATE BASE CHAIN ============ vm.selectFork(BASE_FORK_ID); - Comptroller baseComptroller = Comptroller(addresses.getAddress("UNITROLLER")); + Comptroller baseComptroller = Comptroller( + addresses.getAddress("UNITROLLER") + ); address baseWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); // Validate minting is paused @@ -265,15 +281,14 @@ contract mipx36 is HybridProposal { // Test that minting is actually paused address baseWrsethUnderlying = MErc20(baseWrsethMToken).underlying(); - _testMintPaused( - baseWrsethMToken, - baseWrsethUnderlying - ); + _testMintPaused(baseWrsethMToken, baseWrsethUnderlying); // ============ VALIDATE OPTIMISM CHAIN ============ vm.selectFork(OPTIMISM_FORK_ID); - Comptroller optimismComptroller = Comptroller(addresses.getAddress("UNITROLLER")); + Comptroller optimismComptroller = Comptroller( + addresses.getAddress("UNITROLLER") + ); address optimismWrsethMToken = addresses.getAddress("MOONWELL_wrsETH"); // Validate minting is paused @@ -292,7 +307,9 @@ contract mipx36 is HybridProposal { ChainlinkOracle optimismChainlinkOracle = ChainlinkOracle( addresses.getAddress("CHAINLINK_ORACLE") ); - AggregatorV3Interface optimismFeed = optimismChainlinkOracle.getFeed("wrsETH"); + AggregatorV3Interface optimismFeed = optimismChainlinkOracle.getFeed( + "wrsETH" + ); assertEq( address(optimismFeed), address(optimismWrsethOracle), @@ -301,13 +318,15 @@ contract mipx36 is HybridProposal { // Validate price can be fetched (, int256 optimismPrice, , , ) = optimismFeed.latestRoundData(); - assertGt(uint256(optimismPrice), 0, "Optimism wrsETH price check failed"); + assertGt( + uint256(optimismPrice), + 0, + "Optimism wrsETH price check failed" + ); // Test that minting is actually paused - address optimismWrsethUnderlying = MErc20(optimismWrsethMToken).underlying(); - _testMintPaused( - optimismWrsethMToken, - optimismWrsethUnderlying - ); + address optimismWrsethUnderlying = MErc20(optimismWrsethMToken) + .underlying(); + _testMintPaused(optimismWrsethMToken, optimismWrsethUnderlying); } } diff --git a/proposals/mips/mip-x36/x36.md b/proposals/mips/mip-x36/x36.md index 44b7c7c08..1f394563c 100644 --- a/proposals/mips/mip-x36/x36.md +++ b/proposals/mips/mip-x36/x36.md @@ -2,58 +2,81 @@ ### **Summary** -Following the oracle malfunction involving the wrsETH/ETH feed on November 4, 2025, this proposal seeks to formally disable minting and borrowing in the wrsETH markets on both Base and OP Mainnet and transition these markets to use an exchange-rate feed rather than a market price oracle. +Following the oracle malfunction involving the wrsETH/ETH feed on November 4, +2025, this proposal seeks to formally disable minting and borrowing in the +wrsETH markets on both Base and OP Mainnet and transition these markets to use +an exchange-rate feed rather than a market price oracle. -These steps reduce further risk exposure and set the foundation for a gradual, orderly deprecation of the wrsETH markets. +These steps reduce further risk exposure and set the foundation for a gradual, +orderly deprecation of the wrsETH markets. ### **Background** -At approximately **5:44 AM UTC on November 4**, an oracle malfunction caused the **wrsETH/ETH feed** to report a faulty value, drastically overpricing wrsETH and enabling an attacker to borrow multiple assets using minimal collateral. +At approximately **5:44 AM UTC on November 4**, an oracle malfunction caused the +**wrsETH/ETH feed** to report a faulty value, drastically overpricing wrsETH and +enabling an attacker to borrow multiple assets using minimal collateral. -The wrsETH/USD price is derived by multiplying the Chainlink ETH/USD oracle with the wrsETH/ETH oracle. The wrsETH/ETH oracle erroneously reported **1 wrsETH = 1,649,934.6 ETH**, valuing each token at roughly **$5.8 billion**. This led to approximately **$3.7 million** in bad debt across the Moonwell protocol. +The wrsETH/USD price is derived by multiplying the Chainlink ETH/USD oracle with +the wrsETH/ETH oracle. The wrsETH/ETH oracle erroneously reported **1 wrsETH = +1,649,934.6 ETH**, valuing each token at roughly **$5.8 billion**. This led to +approximately **$3.7 million** in bad debt across the Moonwell protocol. Immediately following the mispricing event: -* Supply and borrow caps for wrsETH were set to effectively zero. +- Supply and borrow caps for wrsETH were set to effectively zero. -* Borrow caps for all Core Markets on Base and Optimism were temporarily reduced to **0.1** to prevent additional over-borrowing. +- Borrow caps for all Core Markets on Base and Optimism were temporarily reduced + to **0.1** to prevent additional over-borrowing. -* All deposits and withdrawals remained open, allowing suppliers to withdraw funds where liquidity was available. +- All deposits and withdrawals remained open, allowing suppliers to withdraw + funds where liquidity was available. ### **Proposal** -To maintain a risk-off stance and begin the safe wind-down of wrsETH exposure, this proposal will: +To maintain a risk-off stance and begin the safe wind-down of wrsETH exposure, +this proposal will: #### 1. Disable minting and borrowing in wrsETH markets on Base and OP Mainnet. -No new wrsETH can be supplied or borrowed. Supply and borrow caps will be set to a near-zero value (as setting caps to zero represents infinity at a contract level). Repayments and withdrawals will remain enabled for existing users. +No new wrsETH can be supplied or borrowed. Supply and borrow caps will be set to +a near-zero value (as setting caps to zero represents infinity at a contract +level). Repayments and withdrawals will remain enabled for existing users. #### 2. Transition to exchange-rate feeds, replacing the existing oracle. -This ensures accurate value representation based on wrsETH's exchange-rate mechanics rather than rely on market rate pricing. +This ensures accurate value representation based on wrsETH's exchange-rate +mechanics rather than rely on market rate pricing. The exchange-rate feeds can be found here: **Base** + - [Chainlink Exchange-Rate Feed](https://data.chain.link/feeds/base/base/wrseth-eth-exchange-rate) - [wrsETH Contract (BaseScan)](https://basescan.org/address/0xe8dD07CCf5BC4922424140E44Eb970F5950725ef) **OP Mainnet** + - [Chainlink Exchange-Rate Feed](https://data.chain.link/feeds/optimism/mainnet/wrseth-eth-exchange-rate) - [wrsETH Contract (Optimism Etherscan)](https://optimistic.etherscan.io/address/0x73b8BE3b653c5896BC34fC87cEBC8AcF4Fb7A545) #### 3. **Prepare the markets for deprecation.** -Once minting and borrowing are disabled and the oracle is transitioned, the community can proceed with a gradual deprecation of wrsETH markets on Base and OP Mainnet. Users will receive advance notice and be encouraged to repay outstanding loans and withdraw wrsETH to avoid liquidation risk during the wind-down process. +Once minting and borrowing are disabled and the oracle is transitioned, the +community can proceed with a gradual deprecation of wrsETH markets on Base and +OP Mainnet. Users will receive advance notice and be encouraged to repay +outstanding loans and withdraw wrsETH to avoid liquidation risk during the +wind-down process. ### **Rationale** -* Disabling mint and borrow eliminates the primary vectors for further exploitation. -* Transitioning to exchange-rate feeds ensures stable and accurate valuation until the market can be fully deprecated. -* The measured approach, risk-off first, gradual deprecation later, balances user protection with operational continuity. +- Disabling mint and borrow eliminates the primary vectors for further + exploitation. +- Transitioning to exchange-rate feeds ensures stable and accurate valuation + until the market can be fully deprecated. +- The measured approach, risk-off first, gradual deprecation later, balances + user protection with operational continuity. ### **Voting Options** -Yes: Disable mint/borrow for wrsETH on Base and OP Mainnet and transition to exchange-rate feeds -No: Maintain the status quo. -Abstain: No preference +Yes: Disable mint/borrow for wrsETH on Base and OP Mainnet and transition to +exchange-rate feeds No: Maintain the status quo. Abstain: No preference diff --git a/test/integration/CrossChainPublishMessageIntegration.t.sol b/test/integration/CrossChainPublishMessageIntegration.t.sol index 03bde03b7..b0e12950c 100644 --- a/test/integration/CrossChainPublishMessageIntegration.t.sol +++ b/test/integration/CrossChainPublishMessageIntegration.t.sol @@ -1,4 +1,3 @@ - // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.19; From 76e41abe0a112a4293800313082018deb1e6c83b Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 11 Nov 2025 13:13:07 -0500 Subject: [PATCH 16/31] rename => mip-x37.sol; core wrapper integration tests passing --- proposals/mips/mip-x37/{x37.sol => mip-x37.sol} | 4 ++-- proposals/mips/mip-x37/x37.md | 1 + proposals/mips/mips.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) rename proposals/mips/mip-x37/{x37.sol => mip-x37.sol} (99%) create mode 100644 proposals/mips/mip-x37/x37.md diff --git a/proposals/mips/mip-x37/x37.sol b/proposals/mips/mip-x37/mip-x37.sol similarity index 99% rename from proposals/mips/mip-x37/x37.sol rename to proposals/mips/mip-x37/mip-x37.sol index fc0308ebf..765e6aff2 100644 --- a/proposals/mips/mip-x37/x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -21,7 +21,7 @@ import {validateProxy} from "@proposals/utils/ProxyUtils.sol"; // 1. deploy new non-upgradeable ChainlinkOEVWrapper contracts for core markets // 2. upgrade existing ChainlinkOEVMorphoWrapper proxy contracts for Morpho markets => test that storage can still be accessed // 3. call setFeed on the ChainlinkOracle for all core markets, to point to the new ChainlinkOEVWrapper contracts -contract x37 is HybridProposal, ChainlinkOracleConfigs, Networks { +contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { string public constant override name = "MIP-X37"; string public constant MORPHO_IMPLEMENTATION_NAME = @@ -34,7 +34,7 @@ contract x37 is HybridProposal, ChainlinkOracleConfigs, Networks { /// @dev description setup constructor() { _setProposalDescription( - bytes(vm.readFile("./proposals/mips/mip-x37/MIP-X37.md")) + bytes(vm.readFile("./proposals/mips/mip-x37/x37.md")) ); } 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 78ce47e1e..94c465ae0 100755 --- a/proposals/mips/mips.json +++ b/proposals/mips/mips.json @@ -3,7 +3,7 @@ "envpath": "", "governor": "MultichainGovernor", "id": 0, - "path": "mip-x37.sol/x37.json", + "path": "mip-x37.sol/mipx37.json", "proposalType": "HybridProposal" }, { From bfb338d84d213789eafb4df52781412fe10ab03d Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 11 Nov 2025 15:29:09 -0500 Subject: [PATCH 17/31] fix morpho tests --- proposals/ChainlinkOracleConfigs.sol | 4 +- src/oracles/ChainlinkOEVMorphoWrapper.sol | 25 ++++++-- ...ChainlinkOEVMorphoWrapperIntegration.t.sol | 64 +++++++++++++++++-- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol index 08c252143..4cb030879 100644 --- a/proposals/ChainlinkOracleConfigs.sol +++ b/proposals/ChainlinkOracleConfigs.sol @@ -86,11 +86,13 @@ abstract contract ChainlinkOracleConfigs is Test { "MOONWELL_MAMO" ) ); + + /// NOTE: stkWELL does not have an equivalent MToken to add reserves to, so use TEMPORAL_GOVERNOR as the fee recipient _MorphoOracleConfigs[BASE_CHAIN_ID].push( MorphoOracleConfig( "CHAINLINK_stkWELL_USD", "CHAINLINK_WELL_USD", - "MOONWELL_WELL" + "TEMPORAL_GOVERNOR" ) ); } diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol index 633ad7009..28c5af515 100644 --- a/src/oracles/ChainlinkOEVMorphoWrapper.sol +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -452,12 +452,27 @@ contract ChainlinkOEVMorphoWrapper is liquidatorFee ); - // transfer the remainder to the protocol's core market for this collateral - EIP20Interface(marketParams.collateralToken).approve( - feeRecipient, - protocolFee + // transfer the remainder to the fee recipient + // if the fee recipient is an MToken, add as reserves + // otherwise, just transfer the tokens directly + EIP20Interface collateralToken = EIP20Interface( + marketParams.collateralToken ); - MErc20(feeRecipient)._addReserves(protocolFee); + + // Check if fee recipient is an MToken by checking if it has isMToken constant + try MErc20(feeRecipient).isMToken() returns (bool isMToken) { + if (isMToken) { + // It's an MToken, add as reserves + collateralToken.approve(feeRecipient, protocolFee); + MErc20(feeRecipient)._addReserves(protocolFee); + } else { + // Not an MToken, just transfer + collateralToken.transfer(feeRecipient, protocolFee); + } + } catch { + // If the call fails, it's not an MToken, just transfer + collateralToken.transfer(feeRecipient, protocolFee); + } emit PriceUpdatedEarlyAndLiquidated( borrower, diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol index 749202575..ae4526bee 100644 --- a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -190,6 +190,8 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is } function testUpdatePriceEarlyAndLiquidate_stkWELL() public { + // Note: stkWELL has no market, so fee recipient should be set to treasury + // The wrapper will automatically detect this and transfer instead of calling _addReserves _testLiquidation( addresses.getAddress("CHAINLINK_stkWELL_USD_ORACLE_PROXY"), addresses.getAddress("STK_GOVTOKEN_PROXY"), @@ -206,8 +208,8 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is addresses.getAddress("MAMO"), addresses.getAddress("MORPHO_CHAINLINK_MAMO_USD_ORACLE"), 0.385e18, - 50_000e18, // More MAMO tokens - 500e18 + 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) ); } @@ -266,7 +268,25 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is ) ); - // Execute liquidation and validate + // 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); @@ -274,6 +294,11 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is uint256 liqLoanBefore = IERC20(loanToken).balanceOf(LIQUIDATOR); uint256 liqCollBefore = IERC20(collToken).balanceOf(LIQUIDATOR); + // Capture fee recipient state before + address feeRecipient = wrapper.feeRecipient(); + uint256 feeStateBefore = _getFeeRecipientState(feeRecipient, collToken); + bool isMToken = _isFeeRecipientMToken(feeRecipient); + wrapper.updatePriceEarlyAndLiquidate( params, BORROWER, @@ -282,6 +307,7 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is ); vm.stopPrank(); + // Assertions assertEq(wrapper.cachedRoundId(), 777); assertGt( liqLoanBefore - IERC20(loanToken).balanceOf(LIQUIDATOR), @@ -293,13 +319,39 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is 0, "no collateral received" ); + + // Verify protocol fee was collected + uint256 feeStateAfter = _getFeeRecipientState(feeRecipient, collToken); assertGt( - IERC20(collToken).balanceOf(wrapper.feeRecipient()), - 0, - "no fee collected" + feeStateAfter, + feeStateBefore, + isMToken + ? "no fee collected (reserves)" + : "no fee collected (balance)" ); } + function _isFeeRecipientMToken( + address feeRecipient + ) internal view returns (bool) { + try MErc20(feeRecipient).isMToken() returns (bool _isMToken) { + return _isMToken; + } catch { + return false; + } + } + + function _getFeeRecipientState( + address feeRecipient, + address collToken + ) internal view returns (uint256) { + if (_isFeeRecipientMToken(feeRecipient)) { + return MErc20(feeRecipient).totalReserves(); + } else { + return IERC20(collToken).balanceOf(feeRecipient); + } + } + function testUpdatePriceEarlyAndLiquidate_RevertArgsZero() public { MarketParams memory params; address mUSDC = addresses.getAddress("MOONWELL_USDC"); From 8e7eda1a859ffdb5eba0f9b721a1b64e9669e99f Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 12 Nov 2025 08:09:53 -0500 Subject: [PATCH 18/31] update ci --- .github/workflows/base-integration.yml | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) 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 From c349f5db2ae75ba6aa87c95a24ab359462b9e746 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 12 Nov 2025 11:33:25 -0500 Subject: [PATCH 19/31] more validations for constructor --- proposals/mips/mip-x37/mip-x37.sol | 165 ++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 5 deletions(-) diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol index 765e6aff2..d7086e115 100644 --- a/proposals/mips/mip-x37/mip-x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -72,10 +72,12 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { // 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); } @@ -251,7 +253,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { vm.stopBroadcast(); } - // FIX: test the contructor setting function _validateFeedsPointToWrappers( Addresses addresses, uint256 chainId @@ -277,6 +278,95 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { } } + 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 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 @@ -309,6 +399,11 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { 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") @@ -317,7 +412,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { addresses.getAddress(wrapperName) ); - // priceFeed and morphoBlue wiring preserved + // Validate priceFeed assertEq( address(wrapper.priceFeed()), addresses.getAddress(morphoConfigs[i].priceFeedName), @@ -326,6 +421,8 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { wrapperName ) ); + + // Validate morphoBlue assertEq( address(wrapper.morphoBlue()), morphoBlue, @@ -335,16 +432,74 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { ) ); + // Validate chainlinkOracle + assertEq( + address(wrapper.chainlinkOracle()), + expectedChainlinkOracle, + string.concat( + "Morpho wrapper chainlinkOracle mismatch for ", + wrapperName + ) + ); + + // Validate feeRecipient + assertEq( + wrapper.feeRecipient(), + addresses.getAddress(morphoConfigs[i].coreMarketAsFeeRecipient), + string.concat( + "Morpho wrapper feeRecipient mismatch for ", + wrapperName + ) + ); + + // Validate feeMultiplier assertEq( wrapper.feeMultiplier(), FEE_MULTIPLIER, string.concat( - "Morpho wrapper not using expected fee multiplier for ", + "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 ) ); - // interface/decimals behavior intact + // Validate owner + assertEq( + wrapper.owner(), + expectedOwner, + string.concat("Morpho wrapper owner mismatch for ", wrapperName) + ); + + // Validate decimals behavior uint8 d = wrapper.decimals(); assertEq( d, @@ -357,7 +512,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { ) ); - // this should be the same as the priceFeed.latestRound() + // Validate latestRoundData behavior (uint80 roundId, int256 answer, , uint256 updatedAt, ) = wrapper .latestRoundData(); assertGt( From f3655e91a1071059b21e2fb9b5611a7177f13a1f Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Fri, 14 Nov 2025 11:48:22 -0500 Subject: [PATCH 20/31] add: real liquidation scenario; fix usd collateral fee split; liquidator keeps 40% of remaining collateral seized after repay amoutn --- proposals/ChainlinkOracleConfigs.sol | 6 + proposals/mips/mip-x37/mip-x37.sol | 2 +- src/oracles/ChainlinkOEVMorphoWrapper.sol | 33 +- src/oracles/ChainlinkOEVWrapper.sol | 69 ++- .../ChainlinkOEVWrapperIntegration.t.sol | 441 +++++++++++++++++- test/utils/Liquidations.sol | 79 ++++ 6 files changed, 569 insertions(+), 61 deletions(-) create mode 100644 test/utils/Liquidations.sol diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol index 4cb030879..7aa0dc0c4 100644 --- a/proposals/ChainlinkOracleConfigs.sol +++ b/proposals/ChainlinkOracleConfigs.sol @@ -44,6 +44,12 @@ abstract contract ChainlinkOracleConfigs is Test { _oracleConfigs[BASE_CHAIN_ID].push( OracleConfig("CHAINLINK_VIRTUAL_USD", "VIRTUAL") ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("CHAINLINK_AERO_ORACLE", "AERO") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("cbETHETH_ORACLE", "cbETH") + ); /// Initialize oracle configurations for Optimism _oracleConfigs[OPTIMISM_CHAIN_ID].push( diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol index d7086e115..03c65790f 100644 --- a/proposals/mips/mip-x37/mip-x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -27,7 +27,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { string public constant MORPHO_IMPLEMENTATION_NAME = "CHAINLINK_OEV_MORPHO_WRAPPER_IMPL"; - uint16 public constant FEE_MULTIPLIER = 9900; + 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; diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol index 28c5af515..4df3505d2 100644 --- a/src/oracles/ChainlinkOEVMorphoWrapper.sol +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -23,6 +23,9 @@ contract ChainlinkOEVMorphoWrapper is /// @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; @@ -556,32 +559,34 @@ contract ChainlinkOEVMorphoWrapper is uint256 collateralReceived, MarketParams memory marketParams ) internal view returns (uint256 liquidatorFee, uint256 protocolFee) { - // Get the loan token price from ChainlinkOracle uint256 loanTokenPrice = _getLoanTokenPrice( EIP20Interface(marketParams.loanToken) ); - - // Get the fully adjusted collateral token price uint256 collateralTokenPrice = _getCollateralTokenPrice( collateralAnswer, EIP20Interface(marketParams.collateralToken) ); - // Calculate USD value of the repay amount - uint256 repayValueUSD = (repayAmount * loanTokenPrice); - uint256 collateralValueUSD = (collateralReceived * - collateralTokenPrice); + 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 receives: collateral worth repay amount + bonus (remainder * feeMultiplier) - uint256 liquidatorPaymentUSD = repayValueUSD + - ((collateralValueUSD - repayValueUSD) * uint256(feeMultiplier)) / + // Liquidator gets the repayment amount + bonus (remainder * feeMultiplier) + uint256 liquidatorUSD = repayUSD + + ((collateralUSD - repayUSD) * uint256(feeMultiplier)) / MAX_BPS; - // Convert USD value back to collateral token amount - // Both prices from oracle are already scaled for token decimals, so simple division works - liquidatorFee = liquidatorPaymentUSD / collateralTokenPrice; + // Convert back to collateral token amount + liquidatorFee = (liquidatorUSD * usdNormalizer) / collateralTokenPrice; - // Protocol gets the remainder protocolFee = collateralReceived - liquidatorFee; } } diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol index 9b4506902..2314b8ef5 100644 --- a/src/oracles/ChainlinkOEVWrapper.sol +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -9,8 +9,6 @@ import {MToken} from "../MToken.sol"; import {EIP20Interface} from "../EIP20Interface.sol"; import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; -import {console} from "forge-std/console.sol"; - /** * @title ChainlinkOEVWrapper * @notice A wrapper for Chainlink price feeds that allows early updates for liquidation @@ -20,6 +18,12 @@ 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; @@ -368,7 +372,7 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { } // execute liquidation and redeem collateral - uint256 collateralReceived = _executeLiquidationAndRedeem( + uint256 collateralSeized = _executeLiquidationAndRedeem( borrower, repayAmount, mTokenCollateral, @@ -377,8 +381,6 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { underlyingCollateral ); - console.log("collateralReceived", collateralReceived); - // Calculate the split of collateral between liquidator and protocol ( uint256 liquidatorFee, @@ -386,7 +388,7 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { ) = _calculateCollateralSplit( repayAmount, collateralAnswer, - collateralReceived, + collateralSeized, mTokenLoan, underlyingCollateral ); @@ -441,7 +443,6 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { (10 ** decimalDelta); } - // Adjust for token decimals (same logic as ChainlinkOracle) uint256 collateralDecimalDelta = uint256(18) - uint256(underlyingCollateral.decimals()); if (collateralDecimalDelta > 0) { @@ -457,7 +458,7 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { /// @param mTokenLoan The mToken market for the loan token /// @param underlyingLoan The underlying loan token interface /// @param underlyingCollateral The underlying collateral token interface - /// @return collateralReceived The amount of underlying collateral received + /// @return collateralSeized The amount of underlying collateral received function _executeLiquidationAndRedeem( address borrower, uint256 repayAmount, @@ -465,16 +466,14 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { address mTokenLoan, EIP20Interface underlyingLoan, EIP20Interface underlyingCollateral - ) internal returns (uint256 collateralReceived) { + ) internal returns (uint256 collateralSeized) { uint256 collateralBefore = underlyingCollateral.balanceOf( address(this) ); uint256 nativeBalanceBefore = address(this).balance; - // approve the mToken loan market to spend the loan tokens for liquidation underlyingLoan.approve(mTokenLoan, repayAmount); - // liquidate the borrower's position: repay their loan and seize their collateral uint256 mTokenCollateralBalanceBefore = MTokenInterface( mTokenCollateral ).balanceOf(address(this)); @@ -487,7 +486,6 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { "ChainlinkOEVWrapper: liquidation failed" ); - // get the amount of mToken collateral received from liquidation uint256 mTokenBalanceDelta = MTokenInterface(mTokenCollateral) .balanceOf(address(this)) - mTokenCollateralBalanceBefore; @@ -507,20 +505,14 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { require(success, "ChainlinkOEVWrapper: WETH deposit failed"); } - console.log( - "underlyingCollateral.balanceOf(address(this))", - underlyingCollateral.balanceOf(address(this)) - ); - console.log("collateralBefore", collateralBefore); - - collateralReceived = + collateralSeized = underlyingCollateral.balanceOf(address(this)) - collateralBefore; } /// @notice Calculate the split of seized collateral between liquidator and fee recipient /// @param repayAmount The amount of loan tokens being repaid - /// @param collateralReceived The amount of collateral tokens seized + /// @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) @@ -528,35 +520,38 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { function _calculateCollateralSplit( uint256 repayAmount, int256 collateralAnswer, - uint256 collateralReceived, + uint256 collateralSeized, address mTokenLoan, EIP20Interface underlyingCollateral ) internal view returns (uint256 liquidatorFee, uint256 protocolFee) { - uint256 loanTokenPrice = chainlinkOracle.getUnderlyingPrice( + uint256 loanPrice = chainlinkOracle.getUnderlyingPrice( MToken(mTokenLoan) ); - - // Get the fully adjusted collateral token price - uint256 collateralTokenPrice = _getCollateralTokenPrice( + uint256 collateralPrice = _getCollateralTokenPrice( collateralAnswer, underlyingCollateral ); - // Calculate USD value of the repay amount - uint256 repayValueUSD = (repayAmount * loanTokenPrice); - uint256 collateralValueUSD = (collateralReceived * - collateralTokenPrice); + 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 receives: collateral worth repay amount + bonus (remainder * feeMultiplier) - uint256 liquidatorPaymentUSD = repayValueUSD + - ((collateralValueUSD - repayValueUSD) * uint256(feeMultiplier)) / + // Liquidator gets the repayment amount + bonus (remainder * feeMultiplier) + uint256 liquidatorUSD = repayUSD + + ((collateralUSD - repayUSD) * uint256(feeMultiplier)) / MAX_BPS; - // Convert USD value back to collateral token amount - // Both prices from oracle are already scaled for token decimals, so simple division works - liquidatorFee = liquidatorPaymentUSD / collateralTokenPrice; + // Convert back to collateral token amount + liquidatorFee = (liquidatorUSD * usdNormalizer) / collateralPrice; - // Protocol gets the remainder - protocolFee = collateralReceived - liquidatorFee; + protocolFee = collateralSeized - liquidatorFee; } } diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 237110493..7cb52a280 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -14,22 +14,25 @@ import {ChainlinkOracle} from "@protocol/oracles/ChainlinkOracle.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; 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"; contract ChainlinkOEVWrapperIntegrationTest is PostProposalCheck, - ChainlinkOracleConfigs + ChainlinkOracleConfigs, + Liquidations { event FeeMultiplierChanged( uint16 oldFeeMultiplier, uint16 newFeeMultiplier ); event PriceUpdatedEarlyAndLiquidated( - address indexed sender, address indexed borrower, uint256 repayAmount, address mTokenCollateral, address mTokenLoan, - uint256 fee + uint256 protocolFee, + uint256 liquidatorFee ); // Array of wrappers to test, resolved from oracle configs @@ -59,13 +62,18 @@ contract ChainlinkOEVWrapperIntegrationTest is abi.encodePacked(oracleConfigs[i].oracleName, "_OEV_WRAPPER") ); if (addresses.isAddressSet(wrapperName)) { - wrappers.push( - ChainlinkOEVWrapper( - payable(addresses.getAddress(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)); } function _perWrapperActor( @@ -596,10 +604,10 @@ contract ChainlinkOEVWrapperIntegrationTest is (10_000 * 10 ** decimals * 1e8) / uint256(currentPrice); - // Borrow at 80% of the collateral factor (safe margin) + // Borrow at 70% of the collateral factor (safe margin) // maxBorrow = $10k * CF, actualBorrow = maxBorrow * 0.8 borrowAmount = - ((10_000 * collateralFactorBps * 80) / (10000 * 100)) * + ((10_000 * collateralFactorBps * 70) / (10000 * 100)) * 1e6; // Result in USDC (6 decimals) } @@ -1193,6 +1201,14 @@ contract ChainlinkOEVWrapperIntegrationTest is } } + /// @notice Simulate some real liquidations from 10/10 + function testRealLiquidations() public { + LiquidationData[] memory liquidations = getLiquidations(); + for (uint256 i = 0; i < liquidations.length; i++) { + _testRealLiquidation(liquidations[i]); + } + } + function _mockValidRound( ChainlinkOEVWrapper wrapper, uint80 roundId_, @@ -1206,4 +1222,411 @@ contract ChainlinkOEVWrapperIntegrationTest is abi.encode(roundId_, price_, uint256(0), block.timestamp, roundId_) ); } + + /// @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 + ); + if (!shouldContinue) { + return; // Position doesn't exist, skip + } + + LiquidationState memory state = _executeLiquidation( + liquidation, + wrapper, + mTokenCollateralAddr, + mTokenBorrowAddr + ); + + _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 + ) + { + string memory mTokenCollateralKey = string( + abi.encodePacked("MOONWELL_", liquidation.collateralToken) + ); + string memory mTokenBorrowKey = string( + abi.encodePacked("MOONWELL_", liquidation.borrowedToken) + ); + + require( + addresses.isAddressSet(mTokenCollateralKey), + "Collateral mToken not found" + ); + require( + addresses.isAddressSet(mTokenBorrowKey), + "Borrow mToken not found" + ); + + mTokenCollateralAddr = addresses.getAddress(mTokenCollateralKey); + mTokenBorrowAddr = addresses.getAddress(mTokenBorrowKey); + + 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) { + vm.rollFork(liquidation.blockNumber - 1); // ensure onchain state + vm.warp(liquidation.timestamp - 1); // ensures mToken accrual timestamps + + // NOTE: doing this caused some liquidations to fail, but then its needed to get past some "delta" errors on others + // uint256 targetTimestamp = liquidation.timestamp - 1; + // vm.store(mTokenBorrowAddr, bytes32(uint256(9)), bytes32(targetTimestamp)); + // vm.store(mTokenCollateralAddr, bytes32(uint256(9)), bytes32(targetTimestamp)); + + 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 + } + + // 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(); + address collateralUnderlying = MErc20(mTokenCollateralAddr) + .underlying(); + + deal(borrowUnderlying, liquidator, repayAmount * 2); + vm.warp(liquidation.timestamp); + + 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(); + state.liquidatorCollateralBefore = IERC20(collateralUnderlying) + .balanceOf(liquidator); + + // 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(); + + // Get balances after liquidation + state.borrowerBorrowAfter = mTokenBorrow.borrowBalanceStored(borrower); + state.borrowerCollateralAfter = mTokenCollateral.balanceOf(borrower); + state.reservesAfter = mTokenCollateral.totalReserves(); + state.liquidatorCollateralAfter = IERC20(collateralUnderlying) + .balanceOf(liquidator); + } + + /// @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) + ); + + protocolFee = _protocolFee; + liquidatorFee = _liquidatorFee; + } + } + } + + /// @notice Struct to hold price and decimal info + struct PriceInfo { + uint256 collateralPriceUSD; + uint256 borrowPriceUSD; + uint8 collateralDecimals; + uint8 borrowDecimals; + } + + /// @notice Verify liquidation results and log + 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 + ); + + _logLiquidationResults(liquidation, state, usdValues); + _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; + } + + /// @notice Calculate USD values from token amounts + /// @dev getUnderlyingPrice returns prices scaled by 1e18 and already adjusted for token decimals + /// So: USD = (amount * price) / 1e18 + function _calculateUSDValues( + LiquidationData memory liquidation, + LiquidationState memory state, + PriceInfo memory priceInfo + ) internal pure returns (USDValues memory) { + uint256 repayAmount = liquidation.repayAmount; + uint256 protocolFeeUSD = (state.protocolFee * + priceInfo.collateralPriceUSD) / 1e18; + uint256 liquidatorFeeUSD = (state.liquidatorFeeReceived * + priceInfo.collateralPriceUSD) / 1e18; + uint256 repayAmountUSD = (repayAmount * priceInfo.borrowPriceUSD) / + 1e18; + + return + USDValues({ + protocolFeeUSD: protocolFeeUSD, + liquidatorFeeUSD: liquidatorFeeUSD, + repayAmountUSD: repayAmountUSD + }); + } + + /// @notice Log liquidation results + function _logLiquidationResults( + LiquidationData memory liquidation, + LiquidationState memory state, + USDValues memory usdValues + ) internal pure { + console2.log("=== Liquidation Results ==="); + console2.log("Borrower:", liquidation.borrower); + console2.log("Liquidator:", liquidation.liquidator); + console2.log("Collateral Token:", liquidation.collateralToken); + console2.log("Borrow Token:", liquidation.borrowedToken); + console2.log("Repay Amount:", liquidation.repayAmount); + console2.log("Repay Amount USD:", usdValues.repayAmountUSD); + console2.log("Protocol Fee:", state.protocolFee); + console2.log("Protocol Fee USD:", usdValues.protocolFeeUSD); + console2.log("Liquidator Fee:", state.liquidatorFeeReceived); + console2.log("Liquidator Fee USD:", usdValues.liquidatorFeeUSD); + console2.log("Borrower Borrow Before:", state.borrowerBorrowBefore); + console2.log("Borrower Borrow After:", state.borrowerBorrowAfter); + console2.log( + "Borrower Collateral Before:", + state.borrowerCollateralBefore + ); + console2.log( + "Borrower Collateral After:", + state.borrowerCollateralAfter + ); + } + + /// @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.protocolFee, 0, "Protocol fee should be > 0"); + assertGt( + state.liquidatorFeeReceived, + 0, + "Liquidator fee should be > 0" + ); + assertGt( + state.reservesAfter, + state.reservesBefore, + "Reserves should increase" + ); + } + + /// @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/utils/Liquidations.sol b/test/utils/Liquidations.sol new file mode 100644 index 000000000..8645f4612 --- /dev/null +++ b/test/utils/Liquidations.sol @@ -0,0 +1,79 @@ +//SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.19; + +/// @notice Struct to represent a real liquidation event +struct LiquidationData { + uint256 timestamp; + uint256 blockNumber; + string borrowedToken; + string collateralToken; + 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 liquidatorCollateralBefore; + uint256 borrowerBorrowAfter; + uint256 borrowerCollateralAfter; + uint256 reservesAfter; + uint256 liquidatorCollateralAfter; + uint256 protocolFee; + uint256 liquidatorFeeReceived; +} + +/// @notice Abstract contract to provide liquidation data from 10/10; used by ChainlinkOEVWrapperIntegration.t.sol +/// https://dune.com/queries/4326964/7267425 +abstract contract Liquidations { + LiquidationData[] internal _liquidations; + + constructor() { + // https://basescan.org/tx/0x24782acfe7faef5bad1e4545a617c733a632e0aa20810f4500fbaf2c05354c7f + _liquidations.push( + LiquidationData({ + timestamp: 1760131433, + blockNumber: 36671043, + borrowedToken: "USDC", + collateralToken: "AERO", + borrower: 0x46560b7207bb490A2115c334E36a70D6aD4BdEBD, + liquidator: 0x4de911f6b0a3ACE9c25cf198Fe6027415051Eb60, + repayAmount: 409205466639, + seizedCollateralAmount: 32669011298294140000000000, + liquidationSizeUSD: 409201783789800250000000000000000 + }) + ); + } + + /// @notice Get all liquidation data + function getLiquidations() public view returns (LiquidationData[] memory) { + LiquidationData[] memory liquidations = new LiquidationData[]( + _liquidations.length + ); + + unchecked { + uint256 liquidationsLength = liquidations.length; + for (uint256 i = 0; i < liquidationsLength; i++) { + liquidations[i] = LiquidationData({ + timestamp: _liquidations[i].timestamp, + blockNumber: _liquidations[i].blockNumber, + borrowedToken: _liquidations[i].borrowedToken, + collateralToken: _liquidations[i].collateralToken, + borrower: _liquidations[i].borrower, + liquidator: _liquidations[i].liquidator, + repayAmount: _liquidations[i].repayAmount, + seizedCollateralAmount: _liquidations[i] + .seizedCollateralAmount, + liquidationSizeUSD: _liquidations[i].liquidationSizeUSD + }); + } + } + + return liquidations; + } +} From d1c673efce880d7a2d843d27ed8d9733400e673e Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Mon, 17 Nov 2025 12:12:47 -0500 Subject: [PATCH 21/31] skip cbETH for now --- .../oracle/ChainlinkOEVWrapperIntegration.t.sol | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 7cb52a280..49e39a5a4 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -560,6 +560,16 @@ contract ChainlinkOEVWrapperIntegrationTest is for (uint256 i = 0; i < wrappers.length; i++) { ChainlinkOEVWrapper wrapper = wrappers[i]; + if ( + keccak256(abi.encodePacked(oracleConfigs[i].symbol)) == + keccak256(abi.encodePacked("cbETH")) + ) { + console2.log( + "Skipping cbETH wrapper due to borrow liquidity issues" + ); + continue; + } + // Get the collateral mToken string memory mTokenKey = string( abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) From 5956bb2923b3521d10424b223a4adc001f648ba5 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Mon, 17 Nov 2025 13:21:12 -0500 Subject: [PATCH 22/31] fix test by mocking eth price --- .../ChainlinkOEVWrapperIntegration.t.sol | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 49e39a5a4..1dd5e3553 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -1092,6 +1092,24 @@ contract ChainlinkOEVWrapperIntegrationTest is address borrower = _borrower(wrapper); uint256 borrowAmount; + // 0) Mock ETH price to fixed $3,000 for deterministic test + { + int256 fixedEthPrice = 3_000 * 1e8; // $3,000 in Chainlink format (1e8) + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + uint80(100), + fixedEthPrice, + uint256(0), + block.timestamp, + uint80(100) + ) + ); + } + // 1) Deposit WETH as collateral { uint256 accrualTsPre = mTokenCollateral.accrualBlockTimestamp(); @@ -1121,7 +1139,10 @@ contract ChainlinkOEVWrapperIntegrationTest is vm.warp(accrualTsPre + 1); } - borrowAmount = 2_600 * 1e6; // 2,600 USDC + // With mocked ETH price at $3,000: + // 1 WETH = $3,000, with 81% CF = $2,430 max borrow + // Borrow $1,800 (74% of max) - safe margin + borrowAmount = 1_800 * 1e6; // 1,800 USDC uint256 currentBorrowCap = comptroller.borrowCaps( address(mTokenBorrow) @@ -1149,9 +1170,12 @@ contract ChainlinkOEVWrapperIntegrationTest is ); } - // 3) Force position underwater by crashing ETH price to $2,000 + // 3) Force position underwater by crashing ETH price to $1,800 { - int256 crashedPrice = 2_000 * 1e8; + // Crash ETH from $3,000 to $1,800 (40% drop) + // After crash: 1 WETH = $1,800, with 81% CF = $1,458 max borrow + // But we borrowed $1,800, so position is underwater + int256 crashedPrice = 1_800 * 1e8; vm.mockCall( address(wrapper.priceFeed()), From 413e63cd5752301f593d4fe31fe89e5e91f37a91 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Mon, 17 Nov 2025 14:19:56 -0500 Subject: [PATCH 23/31] fix test for real liquidations per chain --- .../ChainlinkOEVWrapperIntegration.t.sol | 6 ++++ test/utils/Liquidations.sol | 33 +++++++++++-------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 1dd5e3553..a202a0511 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -1238,6 +1238,12 @@ contract ChainlinkOEVWrapperIntegrationTest is /// @notice Simulate some real liquidations from 10/10 function testRealLiquidations() public { LiquidationData[] memory liquidations = getLiquidations(); + + // 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]); } diff --git a/test/utils/Liquidations.sol b/test/utils/Liquidations.sol index 8645f4612..b3de1edbf 100644 --- a/test/utils/Liquidations.sol +++ b/test/utils/Liquidations.sol @@ -1,6 +1,8 @@ //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 real liquidation event struct LiquidationData { uint256 timestamp; @@ -31,11 +33,13 @@ struct LiquidationState { /// @notice Abstract contract to provide liquidation data from 10/10; used by ChainlinkOEVWrapperIntegration.t.sol /// https://dune.com/queries/4326964/7267425 abstract contract Liquidations { - LiquidationData[] internal _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 - _liquidations.push( + _liquidationsByChain[BASE_CHAIN_ID].push( LiquidationData({ timestamp: 1760131433, blockNumber: 36671043, @@ -50,26 +54,29 @@ abstract contract Liquidations { ); } - /// @notice Get all liquidation data + /// @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[]( - _liquidations.length + chainLiquidations.length ); unchecked { uint256 liquidationsLength = liquidations.length; for (uint256 i = 0; i < liquidationsLength; i++) { liquidations[i] = LiquidationData({ - timestamp: _liquidations[i].timestamp, - blockNumber: _liquidations[i].blockNumber, - borrowedToken: _liquidations[i].borrowedToken, - collateralToken: _liquidations[i].collateralToken, - borrower: _liquidations[i].borrower, - liquidator: _liquidations[i].liquidator, - repayAmount: _liquidations[i].repayAmount, - seizedCollateralAmount: _liquidations[i] + timestamp: chainLiquidations[i].timestamp, + blockNumber: chainLiquidations[i].blockNumber, + borrowedToken: chainLiquidations[i].borrowedToken, + collateralToken: chainLiquidations[i].collateralToken, + borrower: chainLiquidations[i].borrower, + liquidator: chainLiquidations[i].liquidator, + repayAmount: chainLiquidations[i].repayAmount, + seizedCollateralAmount: chainLiquidations[i] .seizedCollateralAmount, - liquidationSizeUSD: _liquidations[i].liquidationSizeUSD + liquidationSizeUSD: chainLiquidations[i].liquidationSizeUSD }); } } From 8a46b780e880bd404e4a5485ac987dfe5b6eed11 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 18 Nov 2025 19:48:06 -0500 Subject: [PATCH 24/31] new contract OEVProtocolFeeRedeemer to receive all oev fees (mtoken or underlying or native); update mip deployment and tests --- proposals/ChainlinkOracleConfigs.sol | 25 +- proposals/mips/mip-x37/mip-x37.sol | 46 +- script/DeployChainlinkOEVWrapper.s.sol | 10 +- src/OEVProtocolFeeRedeemer.sol | 145 ++++ src/oracles/ChainlinkOEVMorphoWrapper.sol | 22 +- src/oracles/ChainlinkOEVWrapper.sol | 103 +-- ...ChainlinkOEVMorphoWrapperIntegration.t.sol | 131 +++- .../ChainlinkOEVWrapperIntegration.t.sol | 676 ++++++++---------- test/unit/ChainlinkOEVWrapperUnit.t.sol | 4 +- test/utils/Liquidations.sol | 2 +- 10 files changed, 666 insertions(+), 498 deletions(-) create mode 100644 src/OEVProtocolFeeRedeemer.sol diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol index 7aa0dc0c4..3bc3898e2 100644 --- a/proposals/ChainlinkOracleConfigs.sol +++ b/proposals/ChainlinkOracleConfigs.sol @@ -6,13 +6,12 @@ import "@utils/ChainIds.sol"; abstract contract ChainlinkOracleConfigs is Test { struct OracleConfig { string oracleName; /// e.g., CHAINLINK_ETH_USD - string symbol; /// e.g., WETH + string symbol; /// e.g., as found in addresses } 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) - string coreMarketAsFeeRecipient; /// e.g.MOONWELL_WELL (the equivalent core maket to call `#_addReserves`) } /// oracle configurations per chain id @@ -79,27 +78,15 @@ abstract contract ChainlinkOracleConfigs is Test { /// Initialize Morpho market configurations for Base _MorphoOracleConfigs[BASE_CHAIN_ID].push( - MorphoOracleConfig( - "CHAINLINK_WELL_USD", - "CHAINLINK_WELL_USD", - "MOONWELL_WELL" - ) + MorphoOracleConfig("CHAINLINK_WELL_USD", "CHAINLINK_WELL_USD") ); _MorphoOracleConfigs[BASE_CHAIN_ID].push( - MorphoOracleConfig( - "CHAINLINK_MAMO_USD", - "CHAINLINK_MAMO_USD", - "MOONWELL_MAMO" - ) + MorphoOracleConfig("CHAINLINK_MAMO_USD", "CHAINLINK_MAMO_USD") ); /// NOTE: stkWELL does not have an equivalent MToken to add reserves to, so use TEMPORAL_GOVERNOR as the fee recipient _MorphoOracleConfigs[BASE_CHAIN_ID].push( - MorphoOracleConfig( - "CHAINLINK_stkWELL_USD", - "CHAINLINK_WELL_USD", - "TEMPORAL_GOVERNOR" - ) + MorphoOracleConfig("CHAINLINK_stkWELL_USD", "CHAINLINK_WELL_USD") ); } @@ -136,9 +123,7 @@ abstract contract ChainlinkOracleConfigs is Test { configs[i] = MorphoOracleConfig({ proxyName: _MorphoOracleConfigs[chainId][i].proxyName, priceFeedName: _MorphoOracleConfigs[chainId][i] - .priceFeedName, - coreMarketAsFeeRecipient: _MorphoOracleConfigs[chainId][i] - .coreMarketAsFeeRecipient + .priceFeedName }); } } diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol index 03c65790f..96c751b4a 100644 --- a/proposals/mips/mip-x37/mip-x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -16,11 +16,13 @@ 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 new non-upgradeable ChainlinkOEVWrapper contracts for core markets -// 2. upgrade existing ChainlinkOEVMorphoWrapper proxy contracts for Morpho markets => test that storage can still be accessed -// 3. call setFeed on the ChainlinkOracle for all core markets, to point to the new ChainlinkOEVWrapper contracts +// 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"; @@ -42,20 +44,24 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { return BASE_FORK_ID; } - // Deploy new instances of ChainlinkOEVWrapper (core markets) and ensure ChainlinkOEVMorphoWrapper implementation exists (Morpho) + // 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); @@ -119,9 +125,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { addresses.getAddress("TEMPORAL_GOVERNOR"), addresses.getAddress("MORPHO_BLUE"), addresses.getAddress("CHAINLINK_ORACLE"), - addresses.getAddress( - morphoConfigs[i].coreMarketAsFeeRecipient - ), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), FEE_MULTIPLIER, MAX_ROUND_DELAY, MAX_DECREMENTS @@ -167,6 +171,19 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { console.log("=== Finished wiring core feeds ==="); } + function _deployOEVProtocolFeeRedeemer(Addresses addresses) internal { + if (addresses.isAddressSet("OEV_PROTOCOL_FEE_REDEEMER")) { + return; + } + + vm.startBroadcast(); + OEVProtocolFeeRedeemer feeRedeemer = new OEVProtocolFeeRedeemer( + addresses.getAddress("MOONWELL_WETH") + ); + 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( @@ -198,6 +215,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { 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 @@ -318,6 +336,16 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { ) ); + // 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(), @@ -445,7 +473,7 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { // Validate feeRecipient assertEq( wrapper.feeRecipient(), - addresses.getAddress(morphoConfigs[i].coreMarketAsFeeRecipient), + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER"), string.concat( "Morpho wrapper feeRecipient mismatch for ", wrapperName diff --git a/script/DeployChainlinkOEVWrapper.s.sol b/script/DeployChainlinkOEVWrapper.s.sol index 0387ff50e..9958f731e 100644 --- a/script/DeployChainlinkOEVWrapper.s.sol +++ b/script/DeployChainlinkOEVWrapper.s.sol @@ -9,12 +9,12 @@ import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.s import {ChainlinkOEVWrapper} from "@protocol/oracles/ChainlinkOEVWrapper.sol"; import {AllChainAddresses as Addresses} from "@proposals/Addresses.sol"; -// TODO: update for new ChainlinkOEVWrapper +/// @dev deprecated, no longer compatible with new wrapper contract DeployChainlinkOEVWrapper is Script { function deploy( - Addresses addresses - ) public returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { - // no longer compatible with old wrapper + Addresses // addresses + ) public pure returns (TransparentUpgradeableProxy, ChainlinkOEVWrapper) { + // no longer compatible with new wrapper return ( TransparentUpgradeableProxy(payable(address(0))), ChainlinkOEVWrapper(payable(address(0))) @@ -26,7 +26,7 @@ contract DeployChainlinkOEVWrapper is Script { TransparentUpgradeableProxy proxy, ChainlinkOEVWrapper implementation ) public view { - // no longer compatible with old wrapper + // no longer compatible with new wrapper } function run() diff --git a/src/OEVProtocolFeeRedeemer.sol b/src/OEVProtocolFeeRedeemer.sol new file mode 100644 index 000000000..b4662817c --- /dev/null +++ b/src/OEVProtocolFeeRedeemer.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity 0.8.19; + +import {MErc20Interface, MTokenInterface} from "./MTokenInterfaces.sol"; +import {EIP20Interface} from "./EIP20Interface.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 { + event ReservesAddedFromOEV(address indexed mToken, uint256 amount); + + address public immutable MOONWELL_WETH; + + /** + * @notice Contract constructor + * @param _moonwellWETH Address for WETH mToken + */ + constructor(address _moonwellWETH) { + MOONWELL_WETH = _moonwellWETH; + } + + /** + * @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 { + ( + 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 { + ( + 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) + { + require( + MTokenInterface(_mToken).isMToken(), + "OEVProtocolFeeRedeemer: not an mToken" + ); + 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); + } +} diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol index 4df3505d2..1856c3fea 100644 --- a/src/oracles/ChainlinkOEVMorphoWrapper.sol +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -456,27 +456,11 @@ contract ChainlinkOEVMorphoWrapper is ); // transfer the remainder to the fee recipient - // if the fee recipient is an MToken, add as reserves - // otherwise, just transfer the tokens directly - EIP20Interface collateralToken = EIP20Interface( - marketParams.collateralToken + EIP20Interface(marketParams.collateralToken).transfer( + feeRecipient, + protocolFee ); - // Check if fee recipient is an MToken by checking if it has isMToken constant - try MErc20(feeRecipient).isMToken() returns (bool isMToken) { - if (isMToken) { - // It's an MToken, add as reserves - collateralToken.approve(feeRecipient, protocolFee); - MErc20(feeRecipient)._addReserves(protocolFee); - } else { - // Not an MToken, just transfer - collateralToken.transfer(feeRecipient, protocolFee); - } - } catch { - // If the call fails, it's not an MToken, just transfer - collateralToken.transfer(feeRecipient, protocolFee); - } - emit PriceUpdatedEarlyAndLiquidated( borrower, actualSeizedAssets, diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol index 2314b8ef5..97a19fc05 100644 --- a/src/oracles/ChainlinkOEVWrapper.sol +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -30,7 +30,10 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { /// @notice The Chainlink price feed this proxy forwards to AggregatorV3Interface public priceFeed; - /// @notice The fee multiplier for the OEV fees + /// @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; @@ -43,6 +46,9 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { /// @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, @@ -83,6 +89,7 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { address _priceFeed, address _owner, address _chainlinkOracle, + address _feeRecipient, uint16 _feeMultiplier, uint256 _maxRoundDelay, uint256 _maxDecrements @@ -111,6 +118,10 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { _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; @@ -118,6 +129,7 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { maxRoundDelay = _maxRoundDelay; maxDecrements = _maxDecrements; chainlinkOracle = IChainlinkOracle(_chainlinkOracle); + feeRecipient = _feeRecipient; _transferOwnership(_owner); } @@ -290,6 +302,22 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { 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) */ @@ -343,6 +371,10 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { MErc20Storage(mTokenCollateral).underlying() ); + MTokenInterface mTokenCollateralInterface = MTokenInterface( + mTokenCollateral + ); + require( address(chainlinkOracle.getFeed(underlyingCollateral.symbol())) == address(this), @@ -371,14 +403,13 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { collateralAnswer = answer; } - // execute liquidation and redeem collateral - uint256 collateralSeized = _executeLiquidationAndRedeem( + // execute liquidation + uint256 collateralSeized = _executeLiquidation( borrower, repayAmount, - mTokenCollateral, + mTokenCollateralInterface, mTokenLoan, - underlyingLoan, - underlyingCollateral + underlyingLoan ); // Calculate the split of collateral between liquidator and protocol @@ -394,11 +425,10 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { ); // transfer the liquidator's payment (repayment + bonus) to the liquidator - underlyingCollateral.transfer(msg.sender, liquidatorFee); + mTokenCollateralInterface.transfer(msg.sender, liquidatorFee); - // transfer the remainder to the protocol - underlyingCollateral.approve(mTokenCollateral, protocolFee); - MErc20(mTokenCollateral)._addReserves(protocolFee); + // transfer the remainder to the fee recipient + mTokenCollateralInterface.transfer(feeRecipient, protocolFee); emit PriceUpdatedEarlyAndLiquidated( borrower, @@ -451,63 +481,36 @@ contract ChainlinkOEVWrapper is Ownable, AggregatorV3Interface { return collateralPricePerUnit; } - /// @notice Execute liquidation and redeem collateral + /// @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 _mTokenLoan The mToken market for the loan token /// @param underlyingLoan The underlying loan token interface - /// @param underlyingCollateral The underlying collateral token interface - /// @return collateralSeized The amount of underlying collateral received - function _executeLiquidationAndRedeem( + /// @return collateralSeized The amount of mToken collateral seized + function _executeLiquidation( address borrower, uint256 repayAmount, - address mTokenCollateral, - address mTokenLoan, - EIP20Interface underlyingLoan, - EIP20Interface underlyingCollateral + MTokenInterface mTokenCollateral, + address _mTokenLoan, + EIP20Interface underlyingLoan ) internal returns (uint256 collateralSeized) { - uint256 collateralBefore = underlyingCollateral.balanceOf( + uint256 mTokenCollateralBalanceBefore = mTokenCollateral.balanceOf( address(this) ); - uint256 nativeBalanceBefore = address(this).balance; - - underlyingLoan.approve(mTokenLoan, repayAmount); - - uint256 mTokenCollateralBalanceBefore = MTokenInterface( - mTokenCollateral - ).balanceOf(address(this)); + underlyingLoan.approve(_mTokenLoan, repayAmount); require( - MErc20Interface(mTokenLoan).liquidateBorrow( + MErc20Interface(_mTokenLoan).liquidateBorrow( borrower, repayAmount, - MTokenInterface(mTokenCollateral) + mTokenCollateral ) == 0, "ChainlinkOEVWrapper: liquidation failed" ); - uint256 mTokenBalanceDelta = MTokenInterface(mTokenCollateral) - .balanceOf(address(this)) - mTokenCollateralBalanceBefore; - - // redeem all the mToken collateral to get the underlying collateral tokens - // Note: mWETH will unwrap to native ETH via WETH_UNWRAPPER - require( - MErc20Interface(mTokenCollateral).redeem(mTokenBalanceDelta) == 0, - "ChainlinkOEVWrapper: redemption failed" - ); - - // If we received native ETH (from mWETH), wrap it back to WETH - uint256 nativeDelta = address(this).balance - nativeBalanceBefore; - if (nativeDelta > 0) { - (bool success, ) = address(underlyingCollateral).call{ - value: nativeDelta - }(abi.encodeWithSignature("deposit()")); - require(success, "ChainlinkOEVWrapper: WETH deposit failed"); - } - collateralSeized = - underlyingCollateral.balanceOf(address(this)) - - collateralBefore; + mTokenCollateral.balanceOf(address(this)) - + mTokenCollateralBalanceBefore; } /// @notice Calculate the split of seized collateral between liquidator and fee recipient diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol index ae4526bee..8d2da9d93 100644 --- a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -10,6 +10,7 @@ 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, @@ -28,6 +29,7 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is ); ChainlinkOEVMorphoWrapper[] public wrappers; + OEVProtocolFeeRedeemer public redeemer; // Test actors address internal constant BORROWER = @@ -40,6 +42,11 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is super.setUp(); vm.selectFork(primaryForkId); + // Get redeemer contract from addresses + redeemer = OEVProtocolFeeRedeemer( + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER") + ); + // Resolve morpho wrappers from shared morpho oracle configurations MorphoOracleConfig[] memory morphoConfigs = getMorphoOracleConfigurations(block.chainid); @@ -293,12 +300,11 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is uint256 liqLoanBefore = IERC20(loanToken).balanceOf(LIQUIDATOR); uint256 liqCollBefore = IERC20(collToken).balanceOf(LIQUIDATOR); + uint256 redeemerCollBefore = IERC20(collToken).balanceOf( + address(redeemer) + ); - // Capture fee recipient state before - address feeRecipient = wrapper.feeRecipient(); - uint256 feeStateBefore = _getFeeRecipientState(feeRecipient, collToken); - bool isMToken = _isFeeRecipientMToken(feeRecipient); - + vm.recordLogs(); wrapper.updatePriceEarlyAndLiquidate( params, BORROWER, @@ -307,6 +313,9 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is ); vm.stopPrank(); + // Parse event to get protocol fee + (uint256 protocolFee, ) = _parseLiquidationEvent(); + // Assertions assertEq(wrapper.cachedRoundId(), 777); assertGt( @@ -320,36 +329,102 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is "no collateral received" ); - // Verify protocol fee was collected - uint256 feeStateAfter = _getFeeRecipientState(feeRecipient, collToken); - assertGt( - feeStateAfter, - feeStateBefore, - isMToken - ? "no fee collected (reserves)" - : "no fee collected (balance)" + // 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)) { + _addReservesAndVerify(mTokenCollateral); + } else { + console2.log( + "No mToken market found 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" + ); + } } - function _isFeeRecipientMToken( - address feeRecipient - ) internal view returns (bool) { - try MErc20(feeRecipient).isMToken() returns (bool _isMToken) { - return _isMToken; - } catch { - return false; + /// @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; + } } } - function _getFeeRecipientState( - address feeRecipient, - address collToken - ) internal view returns (uint256) { - if (_isFeeRecipientMToken(feeRecipient)) { - return MErc20(feeRecipient).totalReserves(); - } else { - return IERC20(collToken).balanceOf(feeRecipient); + /// @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 { diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index a202a0511..e6f45934d 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -16,6 +16,7 @@ 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, @@ -39,6 +40,7 @@ contract ChainlinkOEVWrapperIntegrationTest is ChainlinkOEVWrapper[] public wrappers; Comptroller comptroller; MarketBase public marketBase; + OEVProtocolFeeRedeemer public redeemer; // Test actors address internal constant BORROWER = @@ -74,6 +76,11 @@ contract ChainlinkOEVWrapperIntegrationTest is ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); vm.makePersistent(address(oracle)); + + redeemer = OEVProtocolFeeRedeemer( + addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER") + ); + vm.makePersistent(address(redeemer)); } function _perWrapperActor( @@ -587,198 +594,232 @@ contract ChainlinkOEVWrapperIntegrationTest is } address mTokenBorrowAddr = addresses.getAddress("MOONWELL_USDC"); - // Calculate amounts based on price and collateral factor - uint256 collateralAmount; - uint256 borrowAmount; - { - (, int256 currentPrice, , , ) = wrapper.latestRoundData(); - require(currentPrice > 0, "invalid price"); + // Set up synthetic position + address borrower = _borrower(wrapper); + address liquidator = _liquidator(wrapper); + (, uint256 borrowAmount) = _setupSyntheticPosition( + wrapper, + mTokenCollateralAddr, + mTokenBorrowAddr, + borrower + ); - address underlying = MErc20(mTokenCollateralAddr).underlying(); - (bool success, bytes memory data) = underlying.staticcall( - abi.encodeWithSignature("decimals()") - ); - require(success && data.length >= 32, "decimals() call failed"); - uint8 decimals = abi.decode(data, (uint8)); - - // Get the actual collateral factor for this market - (bool isListed, uint256 collateralFactorMantissa) = comptroller - .markets(mTokenCollateralAddr); - require(isListed, "market not listed"); - // collateralFactorMantissa is scaled by 1e18 (e.g., 0.65e18 = 65%) - uint256 collateralFactorBps = (collateralFactorMantissa * - 10000) / 1e18; // Convert to basis points - - // $10k worth of collateral - collateralAmount = - (10_000 * 10 ** decimals * 1e8) / - uint256(currentPrice); - - // Borrow at 70% of the collateral factor (safe margin) - // maxBorrow = $10k * CF, actualBorrow = maxBorrow * 0.8 - borrowAmount = - ((10_000 * collateralFactorBps * 70) / (10000 * 100)) * - 1e6; // Result in USDC (6 decimals) - } + // Crash price to make position underwater + _crashPriceForLiquidation(wrapper, borrower); + + // Create synthetic liquidation data + LiquidationData memory liquidation = LiquidationData({ + timestamp: block.timestamp, + blockNumber: block.number, + borrowedToken: "USDC", + collateralToken: IERC20( + addresses.getAddress(oracleConfigs[i].symbol) + ).symbol(), + borrower: borrower, + liquidator: liquidator, + repayAmount: borrowAmount / 10, + seizedCollateralAmount: 0, // Will be determined during liquidation + liquidationSizeUSD: 0 // Not used in test + }); - address borrower = _borrower(wrapper); + _testRealLiquidation(liquidation); + } + } - // 1) Deposit collateral - { - MToken mToken = MToken(mTokenCollateralAddr); - if (block.timestamp <= mToken.accrualBlockTimestamp()) { - vm.warp(mToken.accrualBlockTimestamp() + 1); - } + /// @notice Set up synthetic position by depositing collateral and borrowing + /// @return collateralAmount The amount of collateral deposited + /// @return borrowAmount The amount borrowed + function _setupSyntheticPosition( + ChainlinkOEVWrapper wrapper, + address mTokenCollateralAddr, + address mTokenBorrowAddr, + address borrower + ) internal returns (uint256 collateralAmount, uint256 borrowAmount) { + (collateralAmount, borrowAmount) = _calculateSyntheticAmounts( + wrapper, + mTokenCollateralAddr + ); + _depositCollateral( + mTokenCollateralAddr, + mTokenBorrowAddr, + borrower, + collateralAmount + ); + _borrowUSDC(mTokenBorrowAddr, borrower, borrowAmount); + } - // Check and increase supply cap if needed - uint256 supplyCap = comptroller.supplyCaps( - mTokenCollateralAddr - ); - if (supplyCap != 0) { - 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 Calculate collateral and borrow amounts for synthetic position + function _calculateSyntheticAmounts( + ChainlinkOEVWrapper wrapper, + address mTokenCollateralAddr + ) internal view returns (uint256 collateralAmount, uint256 borrowAmount) { + (, int256 currentPrice, , , ) = wrapper.latestRoundData(); + require(currentPrice > 0, "invalid price"); + + address underlying = MErc20(mTokenCollateralAddr).underlying(); + (bool success, bytes memory data) = underlying.staticcall( + abi.encodeWithSignature("decimals()") + ); + require(success && data.length >= 32, "decimals() call failed"); + uint8 decimals = abi.decode(data, (uint8)); - _mintMToken(borrower, mTokenCollateralAddr, collateralAmount); + (bool isListed, uint256 collateralFactorMantissa) = comptroller.markets( + mTokenCollateralAddr + ); + require(isListed, "market not listed"); + uint256 collateralFactorBps = (collateralFactorMantissa * 10000) / 1e18; + + collateralAmount = + (10_000 * 10 ** decimals * 1e8) / + uint256(currentPrice); + borrowAmount = + ((10_000 * collateralFactorBps * 70) / (10000 * 100)) * + 1e6; + } - address[] memory markets = new address[](2); - markets[0] = mTokenCollateralAddr; - markets[1] = mTokenBorrowAddr; - vm.prank(borrower); - comptroller.enterMarkets(markets); - } + /// @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); + } - // 2) Borrow USDC - { - MToken mToken = MToken(mTokenBorrowAddr); - if (block.timestamp <= mToken.accrualBlockTimestamp()) { - vm.warp(mToken.accrualBlockTimestamp() + 1); - } + _adjustSupplyCapIfNeeded(mTokenCollateralAddr, collateralAmount); + _mintMToken(borrower, mTokenCollateralAddr, collateralAmount); - uint256 cap = comptroller.borrowCaps(mTokenBorrowAddr); - uint256 total = mToken.totalBorrows(); - - if (cap != 0 && 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(); - } + address[] memory markets = new address[](2); + markets[0] = mTokenCollateralAddr; + markets[1] = mTokenBorrowAddr; + vm.prank(borrower); + comptroller.enterMarkets(markets); + } - vm.prank(borrower); - assertEq( - MErc20Delegator(payable(mTokenBorrowAddr)).borrow( - borrowAmount - ), - 0, - string( - abi.encodePacked( - "borrow failed for ", - oracleConfigs[i].symbol - ) - ) - ); - } + /// @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(); + } + } - // 3) Crash price 60% to make position underwater - { - (, int256 price, , , ) = wrapper.latestRoundData(); - int256 crashedPrice = (price * 40) / 100; - - vm.mockCall( - address(wrapper.priceFeed()), - abi.encodeWithSelector( - wrapper.priceFeed().latestRoundData.selector - ), - abi.encode( - uint80(777), - crashedPrice, - uint256(0), - block.timestamp, - uint80(777) - ) - ); + /// @notice Borrow USDC + function _borrowUSDC( + address mTokenBorrowAddr, + address borrower, + uint256 borrowAmount + ) internal { + MToken mToken = MToken(mTokenBorrowAddr); + if (block.timestamp <= mToken.accrualBlockTimestamp()) { + vm.warp(mToken.accrualBlockTimestamp() + 1); + } - (uint256 err, , uint256 shortfall) = comptroller - .getAccountLiquidity(borrower); - require(err == 0 && shortfall > 0, "position not underwater"); - } + _adjustBorrowCapIfNeeded(mTokenBorrowAddr, borrowAmount); - // 4) Execute liquidation - { - address liquidator = _liquidator(wrapper); - uint256 repayAmount = borrowAmount / 10; - address borrowUnderlying = MErc20(mTokenBorrowAddr) - .underlying(); - deal(borrowUnderlying, liquidator, repayAmount); + vm.prank(borrower); + assertEq( + MErc20Delegator(payable(mTokenBorrowAddr)).borrow(borrowAmount), + 0, + "borrow failed" + ); + } - vm.startPrank(liquidator); - IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); + /// @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(); + } + } - uint256 borrowBefore = MErc20(mTokenBorrowAddr) - .borrowBalanceStored(borrower); - uint256 collateralBefore = MErc20(mTokenCollateralAddr) - .balanceOf(borrower); - uint256 reservesBefore = MErc20(mTokenCollateralAddr) - .totalReserves(); + /// @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 - if ( - block.timestamp <= - MToken(mTokenBorrowAddr).accrualBlockTimestamp() - ) { - vm.warp( - MToken(mTokenBorrowAddr).accrualBlockTimestamp() + 1 - ); - } + uint80 roundId = 777; + vm.mockCall( + address(wrapper.priceFeed()), + abi.encodeWithSelector( + wrapper.priceFeed().latestRoundData.selector + ), + abi.encode( + roundId, + crashedPrice, + uint256(0), + block.timestamp, + roundId + ) + ); - wrapper.updatePriceEarlyAndLiquidate( - borrower, - repayAmount, - mTokenCollateralAddr, - mTokenBorrowAddr - ); - vm.stopPrank(); + // Verify position is now underwater + (uint256 err, , uint256 shortfall) = comptroller.getAccountLiquidity( + borrower + ); + require(err == 0 && shortfall > 0, "position not underwater"); + } - // 5) Validate results - assertEq(wrapper.cachedRoundId(), 777); - assertLt( - MErc20(mTokenBorrowAddr).borrowBalanceStored(borrower), - borrowBefore - ); - assertLt( - MErc20(mTokenCollateralAddr).balanceOf(borrower), - collateralBefore - ); + /// @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(); - address collateralUnderlying = MErc20(mTokenCollateralAddr) - .underlying(); - assertGt(IERC20(collateralUnderlying).balanceOf(liquidator), 0); - // Protocol fee is now added to mToken reserves - assertGt( - MErc20(mTokenCollateralAddr).totalReserves(), - reservesBefore - ); - } - } + // Verify reserves increased after redemption + assertGt( + reservesAfterRedeem, + reservesBeforeRedeem, + "Reserves should increase after redeeming protocol fees" + ); + + // Verify redeemer no longer has mTokens + assertEq( + MErc20(mTokenCollateralAddr).balanceOf(address(redeemer)), + 0, + "Redeemer should have no mTokens after redemption" + ); } function testUpdatePriceEarlyAndLiquidate_RevertZeroRepay() public { @@ -1082,159 +1123,6 @@ contract ChainlinkOEVWrapperIntegrationTest is } } - function testUpdatePriceEarlyAndLiquidate_RevertRedemptionFailed() 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; - - // 0) Mock ETH price to fixed $3,000 for deterministic test - { - int256 fixedEthPrice = 3_000 * 1e8; // $3,000 in Chainlink format (1e8) - vm.mockCall( - address(wrapper.priceFeed()), - abi.encodeWithSelector( - wrapper.priceFeed().latestRoundData.selector - ), - abi.encode( - uint80(100), - fixedEthPrice, - uint256(0), - block.timestamp, - uint80(100) - ) - ); - } - - // 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 - { - uint256 accrualTsPre = mTokenBorrow.accrualBlockTimestamp(); - if (block.timestamp <= accrualTsPre) { - vm.warp(accrualTsPre + 1); - } - - // With mocked ETH price at $3,000: - // 1 WETH = $3,000, with 81% CF = $2,430 max borrow - // Borrow $1,800 (74% of max) - safe margin - borrowAmount = 1_800 * 1e6; // 1,800 USDC - - 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) Force position underwater by crashing ETH price to $1,800 - { - // Crash ETH from $3,000 to $1,800 (40% drop) - // After crash: 1 WETH = $1,800, with 81% CF = $1,458 max borrow - // But we borrowed $1,800, so position is underwater - int256 crashedPrice = 1_800 * 1e8; - - vm.mockCall( - address(wrapper.priceFeed()), - abi.encodeWithSelector( - wrapper.priceFeed().latestRoundData.selector - ), - abi.encode( - uint80(777), - crashedPrice, - uint256(0), - block.timestamp, - uint80(777) - ) - ); - - (uint256 err, uint256 liq, uint256 shortfall) = comptroller - .getAccountLiquidity(borrower); - assertEq(err, 0, "liquidity error"); - assertEq(liq, 0, "expected no liquidity"); - assertGt(shortfall, 0, "no shortfall created"); - } - - // 4) Mock redeem to fail, then attempt liquidation - { - address liquidator = _liquidator(wrapper); - uint256 repayAmount = borrowAmount / 10; // 260 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); - } - - // Mock redeem to fail (return error code 1) - vm.mockCall( - address(mTokenCollateral), - abi.encodeWithSelector( - MErc20Delegator(payable(address(mTokenCollateral))) - .redeem - .selector - ), - abi.encode(uint256(1)) - ); - - vm.expectRevert(bytes("ChainlinkOEVWrapper: redemption failed")); - wrapper.updatePriceEarlyAndLiquidate( - borrower, - repayAmount, - address(mTokenCollateral), - address(mTokenBorrow) - ); - vm.stopPrank(); - } - } - /// @notice Simulate some real liquidations from 10/10 function testRealLiquidations() public { LiquidationData[] memory liquidations = getLiquidations(); @@ -1308,8 +1196,14 @@ contract ChainlinkOEVWrapperIntegrationTest is ChainlinkOEVWrapper wrapper ) { + // HACK: symbol in addresses not matching onchain token symbol + string memory collateralToken = (keccak256( + bytes(liquidation.collateralToken) + ) == keccak256(bytes("tBTC"))) + ? "TBTC" + : liquidation.collateralToken; string memory mTokenCollateralKey = string( - abi.encodePacked("MOONWELL_", liquidation.collateralToken) + abi.encodePacked("MOONWELL_", collateralToken) ); string memory mTokenBorrowKey = string( abi.encodePacked("MOONWELL_", liquidation.borrowedToken) @@ -1342,13 +1236,12 @@ contract ChainlinkOEVWrapperIntegrationTest is address mTokenBorrowAddr, ChainlinkOEVWrapper wrapper ) internal returns (bool shouldContinue) { - vm.rollFork(liquidation.blockNumber - 1); // ensure onchain state - vm.warp(liquidation.timestamp - 1); // ensures mToken accrual timestamps - - // NOTE: doing this caused some liquidations to fail, but then its needed to get past some "delta" errors on others - // uint256 targetTimestamp = liquidation.timestamp - 1; - // vm.store(mTokenBorrowAddr, bytes32(uint256(9)), bytes32(targetTimestamp)); - // vm.store(mTokenCollateralAddr, bytes32(uint256(9)), bytes32(targetTimestamp)); + // 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); @@ -1364,32 +1257,37 @@ contract ChainlinkOEVWrapperIntegrationTest is return false; // Position doesn't exist, skip } - // 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( + // 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, - uint256(0), - block.timestamp, - latestRoundId - ) - ); - - // Mock getRoundData for previous rounds - _mockPreviousRounds( - wrapper, - latestRoundId, - crashedPrice, - block.timestamp - ); + block.timestamp + ); + } // Verify position is now underwater after price crash (uint256 err, , uint256 shortfall) = comptroller.getAccountLiquidity( @@ -1453,6 +1351,11 @@ contract ChainlinkOEVWrapperIntegrationTest is state.liquidatorCollateralBefore = IERC20(collateralUnderlying) .balanceOf(liquidator); + uint256 liquidatorMTokenBefore = mTokenCollateral.balanceOf(liquidator); + uint256 redeemerMTokenBefore = mTokenCollateral.balanceOf( + address(redeemer) + ); + // Execute liquidation vm.startPrank(liquidator); IERC20(borrowUnderlying).approve(address(wrapper), repayAmount); @@ -1471,6 +1374,46 @@ contract ChainlinkOEVWrapperIntegrationTest is state.liquidatorFeeReceived ) = _parseLiquidationEvent(); + // Verify liquidator received mTokens + uint256 liquidatorMTokenAfter = mTokenCollateral.balanceOf(liquidator); + assertGt( + liquidatorMTokenAfter, + liquidatorMTokenBefore, + "Liquidator should receive mTokens" + ); + assertEq( + 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" + ); + + // Redeem protocol fees and add to reserves + _redeemAndVerifyProtocolFees(mTokenCollateralAddr); + } else { + // When protocolFee is 0, redeemer should not receive any mTokens + assertEq( + redeemerMTokenAfter, + redeemerMTokenBefore, + "Redeemer should not receive mTokens when protocolFee is 0" + ); + } + // Get balances after liquidation state.borrowerBorrowAfter = mTokenBorrow.borrowBalanceStored(borrower); state.borrowerCollateralAfter = mTokenCollateral.balanceOf(borrower); @@ -1636,17 +1579,20 @@ contract ChainlinkOEVWrapperIntegrationTest is state.borrowerCollateralBefore, "Collateral not seized" ); - assertGt(state.protocolFee, 0, "Protocol fee should be > 0"); assertGt( state.liquidatorFeeReceived, 0, "Liquidator fee should be > 0" ); - assertGt( - state.reservesAfter, - state.reservesBefore, - "Reserves should increase" - ); + // 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 diff --git a/test/unit/ChainlinkOEVWrapperUnit.t.sol b/test/unit/ChainlinkOEVWrapperUnit.t.sol index 60ffeb608..65ff50c56 100644 --- a/test/unit/ChainlinkOEVWrapperUnit.t.sol +++ b/test/unit/ChainlinkOEVWrapperUnit.t.sol @@ -9,7 +9,7 @@ import {MockChainlinkOracleWithoutLatestRound} from "@test/mock/MockChainlinkOra 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; @@ -36,6 +36,7 @@ contract ChainlinkOEVWrapperUnitTest is Test { feed, owner, chainlinkOracle, + feeRecipient, defaultFeeBps, defaultMaxRoundDelay, defaultMaxDecrements @@ -56,6 +57,7 @@ contract ChainlinkOEVWrapperUnitTest is Test { address(mockFeed), owner, chainlinkOracle, + feeRecipient, defaultFeeBps, defaultMaxRoundDelay, defaultMaxDecrements diff --git a/test/utils/Liquidations.sol b/test/utils/Liquidations.sol index b3de1edbf..45dcf439a 100644 --- a/test/utils/Liquidations.sol +++ b/test/utils/Liquidations.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.19; import {BASE_CHAIN_ID} from "@utils/ChainIds.sol"; -/// @notice Struct to represent a real liquidation event +/// @notice Struct to represent a liquidation event struct LiquidationData { uint256 timestamp; uint256 blockNumber; From 3af305885c945e4347bd1445f218147b6d8f17c6 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Tue, 18 Nov 2025 20:32:46 -0500 Subject: [PATCH 25/31] lint --- src/oracles/ChainlinkOEVMorphoWrapper.sol | 2 +- src/oracles/ChainlinkOEVWrapper.sol | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/oracles/ChainlinkOEVMorphoWrapper.sol b/src/oracles/ChainlinkOEVMorphoWrapper.sol index 1856c3fea..075e7d0a5 100644 --- a/src/oracles/ChainlinkOEVMorphoWrapper.sol +++ b/src/oracles/ChainlinkOEVMorphoWrapper.sol @@ -9,7 +9,7 @@ import {IMorphoBlue} from "../morpho/IMorphoBlue.sol"; import {MarketParams} from "../morpho/IMetaMorpho.sol"; import {IMorphoChainlinkOracleV2} from "../morpho/IMorphoChainlinkOracleV2.sol"; import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; -import {MErc20} from "../MErc20.sol"; + /** * @title ChainlinkOEVMorphoWrapper * @notice A wrapper for Chainlink price feeds that allows early updates for liquidation diff --git a/src/oracles/ChainlinkOEVWrapper.sol b/src/oracles/ChainlinkOEVWrapper.sol index 97a19fc05..27529206b 100644 --- a/src/oracles/ChainlinkOEVWrapper.sol +++ b/src/oracles/ChainlinkOEVWrapper.sol @@ -4,7 +4,6 @@ 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 {MErc20} from "../MErc20.sol"; import {MToken} from "../MToken.sol"; import {EIP20Interface} from "../EIP20Interface.sol"; import {IChainlinkOracle} from "../interfaces/IChainlinkOracle.sol"; From d9cfb5cd224b1c57267ff8ffb6f8ad4dd94f43a3 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 19 Nov 2025 11:59:48 -0500 Subject: [PATCH 26/31] tests: remove note on stkwell, require borrow --- .../oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol index 8d2da9d93..62bf22b7c 100644 --- a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -197,8 +197,6 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is } function testUpdatePriceEarlyAndLiquidate_stkWELL() public { - // Note: stkWELL has no market, so fee recipient should be set to treasury - // The wrapper will automatically detect this and transfer instead of calling _addReserves _testLiquidation( addresses.getAddress("CHAINLINK_stkWELL_USD_ORACLE_PROXY"), addresses.getAddress("STK_GOVTOKEN_PROXY"), @@ -252,14 +250,7 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is IERC20(collToken).approve(address(morpho), collateralAmount); morpho.supplyCollateral(params, collateralAmount, BORROWER, ""); - // Try to borrow - if it fails due to insufficient liquidity, skip the test - try morpho.borrow(params, borrowAmount, 0, BORROWER, BORROWER) { - // Borrow succeeded, continue with test - } catch { - vm.stopPrank(); - // Market doesn't have liquidity in current fork state, skip test - return; - } + morpho.borrow(params, borrowAmount, 0, BORROWER, BORROWER); vm.stopPrank(); // Mock price crash From 3a926d51a7717d8dea527be3a8099d147bb591eb Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 19 Nov 2025 12:04:48 -0500 Subject: [PATCH 27/31] fix: CHAINLINK_ORACLE_PROXY_ADMIN --- script/templates/CreateMorphoMarket.s.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/script/templates/CreateMorphoMarket.s.sol b/script/templates/CreateMorphoMarket.s.sol index ac9402ccf..815a78eb8 100644 --- a/script/templates/CreateMorphoMarket.s.sol +++ b/script/templates/CreateMorphoMarket.s.sol @@ -275,15 +275,18 @@ contract CreateMorphoMarket is Script, Test { } ProxyAdmin proxyAdmin; - if (!addresses.isAddressSet("CHAINLINK_PROXY_ADMIN")) { + if (!addresses.isAddressSet("CHAINLINK_ORACLE_PROXY_ADMIN")) { proxyAdmin = new ProxyAdmin(); - addresses.addAddress("CHAINLINK_PROXY_ADMIN", address(proxyAdmin)); + addresses.addAddress( + "CHAINLINK_ORACLE_PROXY_ADMIN", + address(proxyAdmin) + ); proxyAdmin.transferOwnership( addresses.getAddress("TEMPORAL_GOVERNOR") ); } else { proxyAdmin = ProxyAdmin( - addresses.getAddress("CHAINLINK_PROXY_ADMIN") + addresses.getAddress("CHAINLINK_ORACLE_PROXY_ADMIN") ); } From 73d1eecfb8ec0f8236a22a658bc29966fb07bda5 Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 19 Nov 2025 15:30:08 -0500 Subject: [PATCH 28/31] fix cbETH test case --- .../ChainlinkOEVWrapperIntegration.t.sol | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index e6f45934d..b8177cd23 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -567,16 +567,6 @@ contract ChainlinkOEVWrapperIntegrationTest is for (uint256 i = 0; i < wrappers.length; i++) { ChainlinkOEVWrapper wrapper = wrappers[i]; - if ( - keccak256(abi.encodePacked(oracleConfigs[i].symbol)) == - keccak256(abi.encodePacked("cbETH")) - ) { - console2.log( - "Skipping cbETH wrapper due to borrow liquidity issues" - ); - continue; - } - // Get the collateral mToken string memory mTokenKey = string( abi.encodePacked("MOONWELL_", oracleConfigs[i].symbol) @@ -653,15 +643,12 @@ contract ChainlinkOEVWrapperIntegrationTest is ChainlinkOEVWrapper wrapper, address mTokenCollateralAddr ) internal view returns (uint256 collateralAmount, uint256 borrowAmount) { - (, int256 currentPrice, , , ) = wrapper.latestRoundData(); - require(currentPrice > 0, "invalid price"); - - address underlying = MErc20(mTokenCollateralAddr).underlying(); - (bool success, bytes memory data) = underlying.staticcall( - abi.encodeWithSignature("decimals()") + // Use oracle's getUnderlyingPrice which normalizes all prices to USD + ChainlinkOracle oracle = ChainlinkOracle(address(comptroller.oracle())); + uint256 priceInUSD = oracle.getUnderlyingPrice( + MToken(mTokenCollateralAddr) ); - require(success && data.length >= 32, "decimals() call failed"); - uint8 decimals = abi.decode(data, (uint8)); + require(priceInUSD > 0, "invalid price"); (bool isListed, uint256 collateralFactorMantissa) = comptroller.markets( mTokenCollateralAddr @@ -669,9 +656,7 @@ contract ChainlinkOEVWrapperIntegrationTest is require(isListed, "market not listed"); uint256 collateralFactorBps = (collateralFactorMantissa * 10000) / 1e18; - collateralAmount = - (10_000 * 10 ** decimals * 1e8) / - uint256(currentPrice); + collateralAmount = (10_000 * 1e18 * 1e18) / priceInUSD; borrowAmount = ((10_000 * collateralFactorBps * 70) / (10000 * 100)) * 1e6; From e688eb3448385959eae05feb0105a8486e61c2dc Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Wed, 19 Nov 2025 17:51:25 -0500 Subject: [PATCH 29/31] fix: mtokens must be whitelisted on redeemer contract --- proposals/ChainlinkOracleConfigs.sol | 42 +- proposals/mips/mip-x37/mip-x37.sol | 19 + src/OEVProtocolFeeRedeemer.sol | 39 +- ...ChainlinkOEVMorphoWrapperIntegration.t.sol | 9 +- .../ChainlinkOEVWrapperIntegration.t.sol | 54 +- test/unit/OEVProtocolFeeRedeemer.t.sol | 506 ++++++++++++++++++ test/utils/Liquidations.sol | 7 + 7 files changed, 613 insertions(+), 63 deletions(-) create mode 100644 test/unit/OEVProtocolFeeRedeemer.t.sol diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol index 3bc3898e2..4a374b22a 100644 --- a/proposals/ChainlinkOracleConfigs.sol +++ b/proposals/ChainlinkOracleConfigs.sol @@ -7,6 +7,7 @@ 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 { @@ -23,57 +24,60 @@ abstract contract ChainlinkOracleConfigs is Test { constructor() { /// Initialize oracle configurations for Base _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_ETH_USD", "WETH") + OracleConfig("CHAINLINK_ETH_USD", "WETH", "MOONWELL_WETH") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_BTC_USD", "cbBTC") + OracleConfig("CHAINLINK_BTC_USD", "cbBTC", "MOONWELL_cbBTC") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_EURC_USD", "EURC") + OracleConfig("CHAINLINK_EURC_USD", "EURC", "MOONWELL_EURC") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY") + OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY", "MOONWELL_WELL") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_USDS_USD", "USDS") + OracleConfig("CHAINLINK_USDS_USD", "USDS", "MOONWELL_USDS") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_TBTC_USD", "TBTC") + OracleConfig("CHAINLINK_TBTC_USD", "TBTC", "MOONWELL_TBTC") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_VIRTUAL_USD", "VIRTUAL") + OracleConfig("CHAINLINK_VIRTUAL_USD", "VIRTUAL", "MOONWELL_VIRTUAL") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("CHAINLINK_AERO_ORACLE", "AERO") + OracleConfig("CHAINLINK_AERO_ORACLE", "AERO", "MOONWELL_AERO") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("cbETHETH_ORACLE", "cbETH") + OracleConfig("cbETHETH_ORACLE", "cbETH", "MOONWELL_cbETH") + ); + _oracleConfigs[BASE_CHAIN_ID].push( + OracleConfig("DAI_ORACLE", "DAI", "MOONWELL_DAI") ); /// Initialize oracle configurations for Optimism _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_ETH_USD", "WETH") + OracleConfig("CHAINLINK_ETH_USD", "WETH", "MOONWELL_WETH") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_USDC_USD", "USDC") + OracleConfig("CHAINLINK_USDC_USD", "USDC", "MOONWELL_USDC") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_DAI_USD", "DAI") + OracleConfig("CHAINLINK_DAI_USD", "DAI", "MOONWELL_DAI") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_USDT_USD", "USDT") + OracleConfig("CHAINLINK_USDT_USD", "USDT", "MOONWELL_USDT") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_WBTC_USD", "WBTC") + OracleConfig("CHAINLINK_WBTC_USD", "WBTC", "MOONWELL_WBTC") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_OP_USD", "OP") + OracleConfig("CHAINLINK_OP_USD", "OP", "MOONWELL_OP") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_VELO_USD", "VELO") + OracleConfig("CHAINLINK_VELO_USD", "VELO", "MOONWELL_VELO") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY") + OracleConfig("CHAINLINK_WELL_USD", "xWELL_PROXY", "") ); /// Initialize Morpho market configurations for Base @@ -84,7 +88,6 @@ abstract contract ChainlinkOracleConfigs is Test { MorphoOracleConfig("CHAINLINK_MAMO_USD", "CHAINLINK_MAMO_USD") ); - /// NOTE: stkWELL does not have an equivalent MToken to add reserves to, so use TEMPORAL_GOVERNOR as the fee recipient _MorphoOracleConfigs[BASE_CHAIN_ID].push( MorphoOracleConfig("CHAINLINK_stkWELL_USD", "CHAINLINK_WELL_USD") ); @@ -102,7 +105,8 @@ abstract contract ChainlinkOracleConfigs is Test { for (uint256 i = 0; i < configLength; i++) { configs[i] = OracleConfig({ oracleName: _oracleConfigs[chainId][i].oracleName, - symbol: _oracleConfigs[chainId][i].symbol + symbol: _oracleConfigs[chainId][i].symbol, + mTokenKey: _oracleConfigs[chainId][i].mTokenKey }); } } diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol index 96c751b4a..8e49d5779 100644 --- a/proposals/mips/mip-x37/mip-x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -180,6 +180,25 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { 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++) { + OracleConfig memory config = oracleConfigs[i]; + + // Only skip if mTokenKey is explicitly set to empty string + if (bytes(config.mTokenKey).length == 0) { + continue; + } + feeRedeemer.whitelistMarket(addresses.getAddress(config.mTokenKey)); + } + + feeRedeemer.transferOwnership( + addresses.getAddress("TEMPORAL_GOVERNOR") + ); + addresses.addAddress("OEV_PROTOCOL_FEE_REDEEMER", address(feeRedeemer)); vm.stopBroadcast(); } diff --git a/src/OEVProtocolFeeRedeemer.sol b/src/OEVProtocolFeeRedeemer.sol index b4662817c..9defdaad3 100644 --- a/src/OEVProtocolFeeRedeemer.sol +++ b/src/OEVProtocolFeeRedeemer.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BSD-3-Clause pragma solidity 0.8.19; -import {MErc20Interface, MTokenInterface} from "./MTokenInterfaces.sol"; +import {MErc20Interface} from "./MTokenInterfaces.sol"; import {EIP20Interface} from "./EIP20Interface.sol"; +import {Ownable} from "@openzeppelin-contracts/contracts/access/Ownable.sol"; /** * @title OEVProtocolFeeRedeemer @@ -11,9 +12,19 @@ import {EIP20Interface} from "./EIP20Interface.sol"; * @dev This contract receives fees from ChainlinkOEVWrapper and ChainlinkOEVMorphoWrapper. Handles mToken, * underlying token, and native ETH balances. */ -contract OEVProtocolFeeRedeemer { +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; /** @@ -22,13 +33,25 @@ contract OEVProtocolFeeRedeemer { */ 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 { + function redeemAndAddReserves( + address _mToken + ) external onlyWhitelistedMarkets(_mToken) { ( MErc20Interface mToken, EIP20Interface underlyingToken @@ -61,7 +84,9 @@ contract OEVProtocolFeeRedeemer { * @notice Add reserves from underlying token balance * @param _mToken Address of the mToken to add reserves to */ - function addReserves(address _mToken) external { + function addReserves( + address _mToken + ) external onlyWhitelistedMarkets(_mToken) { ( MErc20Interface mToken, EIP20Interface underlyingToken @@ -102,10 +127,6 @@ contract OEVProtocolFeeRedeemer { view returns (MErc20Interface mToken, EIP20Interface underlyingToken) { - require( - MTokenInterface(_mToken).isMToken(), - "OEVProtocolFeeRedeemer: not an mToken" - ); mToken = MErc20Interface(_mToken); underlyingToken = EIP20Interface(mToken.underlying()); } @@ -142,4 +163,6 @@ contract OEVProtocolFeeRedeemer { mToken._addReserves(amount); emit ReservesAddedFromOEV(address(mToken), amount); } + + receive() external payable {} } diff --git a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol index 62bf22b7c..208c2f196 100644 --- a/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVMorphoWrapperIntegration.t.sol @@ -44,7 +44,7 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is // Get redeemer contract from addresses redeemer = OEVProtocolFeeRedeemer( - addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER") + payable(addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER")) ); // Resolve morpho wrappers from shared morpho oracle configurations @@ -337,11 +337,14 @@ contract ChainlinkOEVMorphoWrapperIntegrationTest is ); address mTokenCollateral = _findMTokenForUnderlying(collToken); - if (mTokenCollateral != address(0)) { + if ( + mTokenCollateral != address(0) && + redeemer.whitelistedMarkets(mTokenCollateral) + ) { _addReservesAndVerify(mTokenCollateral); } else { console2.log( - "No mToken market found for collateral token", + "No mToken market found or not whitelisted for collateral token", IERC20(collToken).symbol() ); } diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index b8177cd23..a395bb3a1 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -78,7 +78,7 @@ contract ChainlinkOEVWrapperIntegrationTest is vm.makePersistent(address(oracle)); redeemer = OEVProtocolFeeRedeemer( - addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER") + payable(addresses.getAddress("OEV_PROTOCOL_FEE_REDEEMER")) ); vm.makePersistent(address(redeemer)); } @@ -588,7 +588,6 @@ contract ChainlinkOEVWrapperIntegrationTest is address borrower = _borrower(wrapper); address liquidator = _liquidator(wrapper); (, uint256 borrowAmount) = _setupSyntheticPosition( - wrapper, mTokenCollateralAddr, mTokenBorrowAddr, borrower @@ -605,6 +604,8 @@ contract ChainlinkOEVWrapperIntegrationTest is collateralToken: IERC20( addresses.getAddress(oracleConfigs[i].symbol) ).symbol(), + borrowMTokenKey: "MOONWELL_USDC", + collateralMTokenKey: oracleConfigs[i].mTokenKey, borrower: borrower, liquidator: liquidator, repayAmount: borrowAmount / 10, @@ -620,13 +621,11 @@ contract ChainlinkOEVWrapperIntegrationTest is /// @return collateralAmount The amount of collateral deposited /// @return borrowAmount The amount borrowed function _setupSyntheticPosition( - ChainlinkOEVWrapper wrapper, address mTokenCollateralAddr, address mTokenBorrowAddr, address borrower ) internal returns (uint256 collateralAmount, uint256 borrowAmount) { (collateralAmount, borrowAmount) = _calculateSyntheticAmounts( - wrapper, mTokenCollateralAddr ); _depositCollateral( @@ -640,7 +639,6 @@ contract ChainlinkOEVWrapperIntegrationTest is /// @notice Calculate collateral and borrow amounts for synthetic position function _calculateSyntheticAmounts( - ChainlinkOEVWrapper wrapper, address mTokenCollateralAddr ) internal view returns (uint256 collateralAmount, uint256 borrowAmount) { // Use oracle's getUnderlyingPrice which normalizes all prices to USD @@ -1181,30 +1179,19 @@ contract ChainlinkOEVWrapperIntegrationTest is ChainlinkOEVWrapper wrapper ) { - // HACK: symbol in addresses not matching onchain token symbol - string memory collateralToken = (keccak256( - bytes(liquidation.collateralToken) - ) == keccak256(bytes("tBTC"))) - ? "TBTC" - : liquidation.collateralToken; - string memory mTokenCollateralKey = string( - abi.encodePacked("MOONWELL_", collateralToken) - ); - string memory mTokenBorrowKey = string( - abi.encodePacked("MOONWELL_", liquidation.borrowedToken) - ); - require( - addresses.isAddressSet(mTokenCollateralKey), + addresses.isAddressSet(liquidation.collateralMTokenKey), "Collateral mToken not found" ); require( - addresses.isAddressSet(mTokenBorrowKey), + addresses.isAddressSet(liquidation.borrowMTokenKey), "Borrow mToken not found" ); - mTokenCollateralAddr = addresses.getAddress(mTokenCollateralKey); - mTokenBorrowAddr = addresses.getAddress(mTokenBorrowKey); + mTokenCollateralAddr = addresses.getAddress( + liquidation.collateralMTokenKey + ); + mTokenBorrowAddr = addresses.getAddress(liquidation.borrowMTokenKey); bool found; (wrapper, found) = _findWrapperForCollateral( @@ -1445,17 +1432,18 @@ contract ChainlinkOEVWrapperIntegrationTest is address mTokenCollateralAddr, address mTokenBorrowAddr ) internal view { - PriceInfo memory priceInfo = _getPriceInfo( - mTokenCollateralAddr, - mTokenBorrowAddr - ); - USDValues memory usdValues = _calculateUSDValues( - liquidation, - state, - priceInfo - ); - - _logLiquidationResults(liquidation, state, usdValues); + // NOTE: only needed to verify USD values on real liquidations + // PriceInfo memory priceInfo = _getPriceInfo( + // mTokenCollateralAddr, + // mTokenBorrowAddr + // ); + // USDValues memory usdValues = _calculateUSDValues( + // liquidation, + // state, + // priceInfo + // ); + + // _logLiquidationResults(liquidation, state, usdValues); _assertLiquidationResults(state); } 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 index 45dcf439a..e9d8432d9 100644 --- a/test/utils/Liquidations.sol +++ b/test/utils/Liquidations.sol @@ -9,6 +9,8 @@ struct LiquidationData { uint256 blockNumber; string borrowedToken; string collateralToken; + string borrowMTokenKey; + string collateralMTokenKey; address borrower; address liquidator; uint256 repayAmount; @@ -45,6 +47,8 @@ abstract contract Liquidations { blockNumber: 36671043, borrowedToken: "USDC", collateralToken: "AERO", + borrowMTokenKey: "MOONWELL_USDC", + collateralMTokenKey: "MOONWELL_AERO", borrower: 0x46560b7207bb490A2115c334E36a70D6aD4BdEBD, liquidator: 0x4de911f6b0a3ACE9c25cf198Fe6027415051Eb60, repayAmount: 409205466639, @@ -71,6 +75,9 @@ abstract contract Liquidations { 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, From f1fd61868530bf9b88e8e6ae2d48a120972025cf Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Thu, 20 Nov 2025 17:09:03 -0500 Subject: [PATCH 30/31] tests: update logs to reflect mtokens + redemptions; add more real liquidation scenarios (wip) --- .../ChainlinkOEVWrapperIntegration.t.sol | 189 +++++++++++++++--- test/utils/Liquidations.sol | 92 ++++++++- 2 files changed, 247 insertions(+), 34 deletions(-) diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index a395bb3a1..85a8ed546 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -1149,7 +1149,8 @@ contract ChainlinkOEVWrapperIntegrationTest is wrapper ); if (!shouldContinue) { - return; // Position doesn't exist, skip + console2.log("Position doesn't exist, skipping"); + return; } LiquidationState memory state = _executeLiquidation( @@ -1313,6 +1314,24 @@ contract ChainlinkOEVWrapperIntegrationTest is deal(borrowUnderlying, liquidator, repayAmount * 2); vm.warp(liquidation.timestamp); + // WIP + // // 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); @@ -1320,8 +1339,6 @@ contract ChainlinkOEVWrapperIntegrationTest is state.borrowerBorrowBefore = mTokenBorrow.borrowBalanceStored(borrower); state.borrowerCollateralBefore = mTokenCollateral.balanceOf(borrower); state.reservesBefore = mTokenCollateral.totalReserves(); - state.liquidatorCollateralBefore = IERC20(collateralUnderlying) - .balanceOf(liquidator); uint256 liquidatorMTokenBefore = mTokenCollateral.balanceOf(liquidator); uint256 redeemerMTokenBefore = mTokenCollateral.balanceOf( @@ -1375,8 +1392,19 @@ contract ChainlinkOEVWrapperIntegrationTest is "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( @@ -1384,14 +1412,13 @@ contract ChainlinkOEVWrapperIntegrationTest is redeemerMTokenBefore, "Redeemer should not receive mTokens when protocolFee is 0" ); + state.protocolFeeRedeemed = 0; } - // Get balances after liquidation + // Get balances after liquidation and redemption state.borrowerBorrowAfter = mTokenBorrow.borrowBalanceStored(borrower); state.borrowerCollateralAfter = mTokenCollateral.balanceOf(borrower); state.reservesAfter = mTokenCollateral.totalReserves(); - state.liquidatorCollateralAfter = IERC20(collateralUnderlying) - .balanceOf(liquidator); } /// @notice Parse liquidation event to extract fees @@ -1433,17 +1460,23 @@ contract ChainlinkOEVWrapperIntegrationTest is address mTokenBorrowAddr ) internal view { // NOTE: only needed to verify USD values on real liquidations - // PriceInfo memory priceInfo = _getPriceInfo( - // mTokenCollateralAddr, - // mTokenBorrowAddr - // ); - // USDValues memory usdValues = _calculateUSDValues( - // liquidation, - // state, - // priceInfo - // ); - - // _logLiquidationResults(liquidation, state, usdValues); + PriceInfo memory priceInfo = _getPriceInfo( + mTokenCollateralAddr, + mTokenBorrowAddr + ); + USDValues memory usdValues = _calculateUSDValues( + liquidation, + state, + priceInfo, + mTokenCollateralAddr + ); + + _logLiquidationResults( + liquidation, + state, + usdValues, + mTokenCollateralAddr + ); _assertLiquidationResults(state); } @@ -1483,29 +1516,54 @@ contract ChainlinkOEVWrapperIntegrationTest is 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 = (amount * price) / 1e18 + /// So: USD = (underlyingAmount * price) / 1e18 function _calculateUSDValues( LiquidationData memory liquidation, LiquidationState memory state, - PriceInfo memory priceInfo - ) internal pure returns (USDValues memory) { + PriceInfo memory priceInfo, + address mTokenCollateralAddr + ) internal view returns (USDValues memory) { uint256 repayAmount = liquidation.repayAmount; - uint256 protocolFeeUSD = (state.protocolFee * + + // 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 = (state.liquidatorFeeReceived * + 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 + repayAmountUSD: repayAmountUSD, + protocolFeeUnderlying: protocolFeeUnderlying, + liquidatorFeeUnderlying: liquidatorFeeUnderlying, + protocolFeeRedeemedUSD: protocolFeeRedeemedUSD }); } @@ -1513,29 +1571,100 @@ contract ChainlinkOEVWrapperIntegrationTest is function _logLiquidationResults( LiquidationData memory liquidation, LiquidationState memory state, - USDValues memory usdValues - ) internal pure { - console2.log("=== Liquidation Results ==="); + 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("Protocol Fee:", state.protocolFee); + 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); - console2.log("Liquidator Fee:", state.liquidatorFeeReceived); + 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 + ); 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:", + "Borrower Collateral Before (mTokens):", state.borrowerCollateralBefore ); console2.log( - "Borrower Collateral After:", + "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 diff --git a/test/utils/Liquidations.sol b/test/utils/Liquidations.sol index e9d8432d9..7cf1f751a 100644 --- a/test/utils/Liquidations.sol +++ b/test/utils/Liquidations.sol @@ -23,13 +23,12 @@ struct LiquidationState { uint256 borrowerBorrowBefore; uint256 borrowerCollateralBefore; uint256 reservesBefore; - uint256 liquidatorCollateralBefore; uint256 borrowerBorrowAfter; uint256 borrowerCollateralAfter; uint256 reservesAfter; - uint256 liquidatorCollateralAfter; - uint256 protocolFee; - uint256 liquidatorFeeReceived; + 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 @@ -56,6 +55,91 @@ abstract contract Liquidations { 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 From 2e3b3a00dec96524b7b7c3b320a99172833e3bec Mon Sep 17 00:00:00 2001 From: Carlos Beltran Date: Fri, 21 Nov 2025 16:50:47 -0500 Subject: [PATCH 31/31] add all markets; update tests; remove logs from mip --- proposals/ChainlinkOracleConfigs.sol | 62 ++++++++++++-- proposals/mips/mip-x37/mip-x37.sol | 36 ++------ .../ChainlinkOEVWrapperIntegration.t.sol | 83 +++++++++++++------ 3 files changed, 120 insertions(+), 61 deletions(-) diff --git a/proposals/ChainlinkOracleConfigs.sol b/proposals/ChainlinkOracleConfigs.sol index 4a374b22a..46c1d2d84 100644 --- a/proposals/ChainlinkOracleConfigs.sol +++ b/proposals/ChainlinkOracleConfigs.sol @@ -21,17 +21,47 @@ abstract contract ChainlinkOracleConfigs is Test { /// 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") ); @@ -41,43 +71,61 @@ abstract contract ChainlinkOracleConfigs is Test { _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_AERO_ORACLE", "AERO", "MOONWELL_AERO") + OracleConfig("CHAINLINK_MORPHO_USD", "MORPHO", "MOONWELL_MORPHO") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("cbETHETH_ORACLE", "cbETH", "MOONWELL_cbETH") + OracleConfig("CHAINLINK_cbXRP_USD", "cbXRP", "MOONWELL_cbXRP") ); _oracleConfigs[BASE_CHAIN_ID].push( - OracleConfig("DAI_ORACLE", "DAI", "MOONWELL_DAI") + OracleConfig("CHAINLINK_MAMO_USD", "MAMO", "MOONWELL_MAMO") ); /// Initialize oracle configurations for Optimism _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_ETH_USD", "WETH", "MOONWELL_WETH") + OracleConfig("CHAINLINK_USDC_USD", "USDC", "MOONWELL_USDC") ); _oracleConfigs[OPTIMISM_CHAIN_ID].push( - OracleConfig("CHAINLINK_USDC_USD", "USDC", "MOONWELL_USDC") + 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_USDT_USD", "USDT", "MOONWELL_USDT") + 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_WELL_USD", "xWELL_PROXY", "") + OracleConfig("CHAINLINK_USDT_USD", "USDT0", "MOONWELL_USDT0") ); /// Initialize Morpho market configurations for Base diff --git a/proposals/mips/mip-x37/mip-x37.sol b/proposals/mips/mip-x37/mip-x37.sol index 8e49d5779..570769c2a 100644 --- a/proposals/mips/mip-x37/mip-x37.sol +++ b/proposals/mips/mip-x37/mip-x37.sol @@ -141,7 +141,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { function _wireCoreFeeds(Addresses addresses, uint256 chainId) internal { OracleConfig[] memory oracleConfigs = getOracleConfigurations(chainId); - console.log("=== Wiring %d core feeds ===", oracleConfigs.length); for (uint256 i = 0; i < oracleConfigs.length; i++) { OracleConfig memory config = oracleConfigs[i]; @@ -153,10 +152,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { .symbol(); address wrapperAddress = addresses.getAddress(wrapperName); - console.log("Feed %d - Symbol: %s", i, symbol); - console.log(" Wrapper name: %s", wrapperName); - console.log(" Wrapper address: %s", wrapperAddress); - console.log(" Pushed setFeed action to ChainlinkOracle"); _pushAction( chainlinkOracle, @@ -168,7 +163,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { string.concat("Set feed to OEV wrapper for ", symbol) ); } - console.log("=== Finished wiring core feeds ==="); } function _deployOEVProtocolFeeRedeemer(Addresses addresses) internal { @@ -186,13 +180,9 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { block.chainid ); for (uint256 i = 0; i < oracleConfigs.length; i++) { - OracleConfig memory config = oracleConfigs[i]; - - // Only skip if mTokenKey is explicitly set to empty string - if (bytes(config.mTokenKey).length == 0) { - continue; - } - feeRedeemer.whitelistMarket(addresses.getAddress(config.mTokenKey)); + feeRedeemer.whitelistMarket( + addresses.getAddress(oracleConfigs[i].mTokenKey) + ); } feeRedeemer.transferOwnership( @@ -214,11 +204,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { return; } - console.log( - "Deploying %d core wrappers for chain %d", - oracleConfigs.length, - block.chainid - ); vm.startBroadcast(); for (uint256 i = 0; i < oracleConfigs.length; i++) { @@ -228,8 +213,6 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { abi.encodePacked(config.oracleName, "_OEV_WRAPPER") ); - console.log("--- Wrapper %d: %s ---", i, wrapperName); - ChainlinkOEVWrapper wrapper = new ChainlinkOEVWrapper( addresses.getAddress(config.oracleName), addresses.getAddress("TEMPORAL_GOVERNOR"), @@ -239,29 +222,28 @@ contract mipx37 is HybridProposal, ChainlinkOracleConfigs, Networks { MAX_ROUND_DELAY, MAX_DECREMENTS ); - console.log("1. Deployed new wrapper at: %s", address(wrapper)); // Set existing wrapper to deprecated and add new wrapper if (addresses.isAddressSet(wrapperName)) { address oldWrapper = addresses.getAddress(wrapperName); - console.log("2. Old wrapper found at: %s", oldWrapper); string memory deprecatedName = string( abi.encodePacked(wrapperName, "_DEPRECATED") ); - addresses.addAddress(deprecatedName, oldWrapper); - console.log("3. Set old wrapper as: %s", deprecatedName); + + if (addresses.isAddressSet(deprecatedName)) { + addresses.changeAddress(deprecatedName, oldWrapper, true); + } else { + addresses.addAddress(deprecatedName, oldWrapper); + } addresses.changeAddress(wrapperName, address(wrapper), true); - console.log("4. Changed %s to new wrapper", wrapperName); } else { addresses.addAddress(wrapperName, address(wrapper)); - console.log("2. Added new wrapper (no previous wrapper)"); } } vm.stopBroadcast(); - console.log("Finished deploying core wrappers"); } function _deployMorphoWrappers(Addresses addresses) internal { diff --git a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol index 85a8ed546..76848a139 100644 --- a/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol +++ b/test/integration/oracle/ChainlinkOEVWrapperIntegration.t.sol @@ -480,6 +480,8 @@ contract ChainlinkOEVWrapperIntegrationTest is // 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) @@ -565,6 +567,8 @@ contract ChainlinkOEVWrapperIntegrationTest is ); for (uint256 i = 0; i < wrappers.length; i++) { + vm.clearMockedCalls(); + ChainlinkOEVWrapper wrapper = wrappers[i]; // Get the collateral mToken @@ -575,14 +579,13 @@ contract ChainlinkOEVWrapperIntegrationTest is address mTokenCollateralAddr = addresses.getAddress(mTokenKey); - // Use USDC as borrow token - if ( - !addresses.isAddressSet("MOONWELL_USDC") || - addresses.getAddress("MOONWELL_USDC") == mTokenCollateralAddr - ) { - continue; - } - address mTokenBorrowAddr = addresses.getAddress("MOONWELL_USDC"); + // 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); @@ -600,11 +603,11 @@ contract ChainlinkOEVWrapperIntegrationTest is LiquidationData memory liquidation = LiquidationData({ timestamp: block.timestamp, blockNumber: block.number, - borrowedToken: "USDC", + borrowedToken: borrowTokenSymbol, collateralToken: IERC20( addresses.getAddress(oracleConfigs[i].symbol) ).symbol(), - borrowMTokenKey: "MOONWELL_USDC", + borrowMTokenKey: borrowMTokenKey, collateralMTokenKey: oracleConfigs[i].mTokenKey, borrower: borrower, liquidator: liquidator, @@ -617,6 +620,28 @@ contract ChainlinkOEVWrapperIntegrationTest is } } + /// @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 @@ -626,7 +651,8 @@ contract ChainlinkOEVWrapperIntegrationTest is address borrower ) internal returns (uint256 collateralAmount, uint256 borrowAmount) { (collateralAmount, borrowAmount) = _calculateSyntheticAmounts( - mTokenCollateralAddr + mTokenCollateralAddr, + mTokenBorrowAddr ); _depositCollateral( mTokenCollateralAddr, @@ -634,12 +660,13 @@ contract ChainlinkOEVWrapperIntegrationTest is borrower, collateralAmount ); - _borrowUSDC(mTokenBorrowAddr, borrower, borrowAmount); + _borrow(mTokenBorrowAddr, borrower, borrowAmount); } /// @notice Calculate collateral and borrow amounts for synthetic position function _calculateSyntheticAmounts( - address mTokenCollateralAddr + 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())); @@ -654,10 +681,13 @@ contract ChainlinkOEVWrapperIntegrationTest is 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)) * - 1e6; + (10 ** borrowDecimals); } /// @notice Deposit collateral and enter markets @@ -706,8 +736,8 @@ contract ChainlinkOEVWrapperIntegrationTest is } } - /// @notice Borrow USDC - function _borrowUSDC( + /// @notice Borrow USDC or DAI + function _borrow( address mTokenBorrowAddr, address borrower, uint256 borrowAmount @@ -1160,12 +1190,14 @@ contract ChainlinkOEVWrapperIntegrationTest is mTokenBorrowAddr ); - _verifyLiquidationResults( - liquidation, - state, - mTokenCollateralAddr, - mTokenBorrowAddr - ); + _assertLiquidationResults(state); + + // _verifyLiquidationResults( + // liquidation, + // state, + // mTokenCollateralAddr, + // mTokenBorrowAddr + // ); } /// @notice Setup liquidation by getting addresses and finding wrapper @@ -1308,13 +1340,10 @@ contract ChainlinkOEVWrapperIntegrationTest is uint256 repayAmount = liquidation.repayAmount; address borrowUnderlying = MErc20(mTokenBorrowAddr).underlying(); - address collateralUnderlying = MErc20(mTokenCollateralAddr) - .underlying(); - deal(borrowUnderlying, liquidator, repayAmount * 2); vm.warp(liquidation.timestamp); - // WIP + // 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) { @@ -1453,13 +1482,13 @@ contract ChainlinkOEVWrapperIntegrationTest is } /// @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 { - // NOTE: only needed to verify USD values on real liquidations PriceInfo memory priceInfo = _getPriceInfo( mTokenCollateralAddr, mTokenBorrowAddr