Skip to content

Commit 2fb0162

Browse files
committed
add ChaininkOEVMorphoWrapper
1 parent 7ee5443 commit 2fb0162

File tree

1 file changed

+388
-0
lines changed

1 file changed

+388
-0
lines changed
Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
// SPDX-License-Identifier: BSD-3-Clause
2+
pragma solidity 0.8.19;
3+
4+
import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol";
5+
import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol";
6+
import "./AggregatorV3Interface.sol";
7+
import {EIP20Interface} from "../EIP20Interface.sol";
8+
import {IMorphoBlue} from "../morpho/IMorphoBlue.sol";
9+
import {MarketParams} from "../morpho/IMetaMorpho.sol";
10+
/**
11+
* @title ChainlinkOEVMorphoWrapper
12+
* @notice A wrapper for Chainlink price feeds that allows early updates for liquidation
13+
* @dev This contract implements the AggregatorV3Interface and adds OEV (Oracle Extractable Value) functionality
14+
*/
15+
contract ChainlinkOEVMorphoWrapper is
16+
Initializable,
17+
OwnableUpgradeable,
18+
AggregatorV3Interface
19+
{
20+
/// @notice The maximum basis points for the fee multiplier
21+
uint16 public constant MAX_BPS = 10000;
22+
23+
/// @notice The Chainlink price feed this proxy forwards to
24+
AggregatorV3Interface public priceFeed;
25+
26+
/// @notice The address that will receive the OEV fees
27+
address public feeRecipient;
28+
29+
/// @notice The fee multiplier for the OEV fees
30+
/// @dev Represented as a percentage
31+
uint16 public feeMultiplier;
32+
33+
/// @notice The last cached round id
34+
uint256 public cachedRoundId;
35+
36+
/// @notice The max round delay
37+
uint256 public maxRoundDelay;
38+
39+
/// @notice The max decrements
40+
uint256 public maxDecrements;
41+
42+
/// @notice The Morpho Blue contract address
43+
IMorphoBlue public morphoBlue;
44+
45+
/// @notice Emitted when the fee recipient is changed
46+
event FeeRecipientChanged(address oldFeeRecipient, address newFeeRecipient);
47+
48+
/// @notice Emitted when the fee multiplier is changed
49+
event FeeMultiplierChanged(
50+
uint16 oldFeeMultiplier,
51+
uint16 newFeeMultiplier
52+
);
53+
54+
/// @notice Emitted when the price is updated early and liquidated
55+
event PriceUpdatedEarlyAndLiquidated(
56+
address indexed sender,
57+
address indexed borrower,
58+
uint256 seizedAssets,
59+
uint256 repaidAssets,
60+
uint256 fee
61+
);
62+
63+
/// @custom:oz-upgrades-unsafe-allow constructor
64+
constructor() {
65+
_disableInitializers();
66+
}
67+
68+
/**
69+
* @notice Initialize the proxy with a price feed address
70+
* @param _priceFeed Address of the Chainlink price feed to forward calls to
71+
* @param _owner Address that will own this contract
72+
* @param _feeRecipient Address that will receive the OEV fees
73+
* @param _feeMultiplier The fee multiplier for the OEV fees
74+
* @param _maxRoundDelay The max round delay
75+
* @param _maxDecrements The max decrements
76+
* @param _morphoBlue Address of the Morpho Blue contract
77+
*/
78+
function initialize(
79+
address _priceFeed,
80+
address _owner,
81+
address _feeRecipient,
82+
uint16 _feeMultiplier,
83+
uint256 _maxRoundDelay,
84+
uint256 _maxDecrements,
85+
address _morphoBlue
86+
) public initializer {
87+
require(
88+
_priceFeed != address(0),
89+
"ChainlinkOEVMorphoWrapper: price feed cannot be zero address"
90+
);
91+
require(
92+
_owner != address(0),
93+
"ChainlinkOEVMorphoWrapper: owner cannot be zero address"
94+
);
95+
require(
96+
_feeRecipient != address(0),
97+
"ChainlinkOEVMorphoWrapper: fee recipient cannot be zero address"
98+
);
99+
require(
100+
_feeMultiplier <= MAX_BPS,
101+
"ChainlinkOEVMorphoWrapper: fee multiplier cannot be greater than MAX_BPS"
102+
);
103+
require(
104+
_maxRoundDelay > 0,
105+
"ChainlinkOEVMorphoWrapper: max round delay cannot be zero"
106+
);
107+
require(
108+
_maxDecrements > 0,
109+
"ChainlinkOEVMorphoWrapper: max decrements cannot be zero"
110+
);
111+
require(
112+
_morphoBlue != address(0),
113+
"ChainlinkOEVMorphoWrapper: morpho blue cannot be zero address"
114+
);
115+
__Ownable_init();
116+
117+
priceFeed = AggregatorV3Interface(_priceFeed);
118+
cachedRoundId = priceFeed.latestRound();
119+
maxRoundDelay = _maxRoundDelay;
120+
maxDecrements = _maxDecrements;
121+
morphoBlue = IMorphoBlue(_morphoBlue);
122+
123+
_transferOwnership(_owner);
124+
}
125+
126+
/**
127+
* @notice Returns the number of decimals in the price feed
128+
* @return The number of decimals
129+
*/
130+
function decimals() external view override returns (uint8) {
131+
return priceFeed.decimals();
132+
}
133+
134+
/**
135+
* @notice Returns a description of the price feed
136+
* @return The description string
137+
*/
138+
function description() external view override returns (string memory) {
139+
return priceFeed.description();
140+
}
141+
142+
/**
143+
* @notice Returns the version number of the price feed
144+
* @return The version number
145+
*/
146+
function version() external view override returns (uint256) {
147+
return priceFeed.version();
148+
}
149+
150+
/**
151+
* @notice Returns data for a specific round
152+
* @param _roundId The round ID to retrieve data for
153+
* @return roundId The round ID
154+
* @return answer The price reported in this round
155+
* @return startedAt The timestamp when the round started
156+
* @return updatedAt The timestamp when the round was updated
157+
* @return answeredInRound The round ID in which the answer was computed
158+
*/
159+
function getRoundData(
160+
uint80 _roundId
161+
)
162+
external
163+
view
164+
override
165+
returns (
166+
uint80 roundId,
167+
int256 answer,
168+
uint256 startedAt,
169+
uint256 updatedAt,
170+
uint80 answeredInRound
171+
)
172+
{
173+
(roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed
174+
.getRoundData(_roundId);
175+
_validateRoundData(roundId, answer, updatedAt, answeredInRound);
176+
}
177+
178+
/**
179+
* @notice Returns data from the latest round, with OEV protection mechanism
180+
* @dev If the latest round hasn't been paid for (via updatePriceEarlyAndLiquidate) and is recent,
181+
* this function will return data from a previous round instead
182+
* @return roundId The round ID
183+
* @return answer The latest price
184+
* @return startedAt The timestamp when the round started
185+
* @return updatedAt The timestamp when the round was updated
186+
* @return answeredInRound The round ID in which the answer was computed
187+
*/
188+
function latestRoundData()
189+
external
190+
view
191+
override
192+
returns (
193+
uint80 roundId,
194+
int256 answer,
195+
uint256 startedAt,
196+
uint256 updatedAt,
197+
uint80 answeredInRound
198+
)
199+
{
200+
(roundId, answer, startedAt, updatedAt, answeredInRound) = priceFeed
201+
.latestRoundData();
202+
203+
// The default behavior is to delay the price update unless someone has paid for the current round.
204+
// If the current round is not too old (maxRoundDelay seconds) and hasn't been paid for,
205+
// attempt to find the most recent valid round by checking previous rounds
206+
if (
207+
roundId != cachedRoundId &&
208+
block.timestamp < updatedAt + maxRoundDelay
209+
) {
210+
// start from the previous round
211+
uint256 currentRoundId = roundId - 1;
212+
213+
for (uint256 i = 0; i < maxDecrements && currentRoundId > 0; i++) {
214+
try priceFeed.getRoundData(uint80(currentRoundId)) returns (
215+
uint80 r,
216+
int256 a,
217+
uint256 s,
218+
uint256 u,
219+
uint80 ar
220+
) {
221+
// previous round data found, update the round data
222+
roundId = r;
223+
answer = a;
224+
startedAt = s;
225+
updatedAt = u;
226+
answeredInRound = ar;
227+
break;
228+
} catch {
229+
// previous round data not found, continue to the next decrement
230+
currentRoundId--;
231+
}
232+
}
233+
}
234+
_validateRoundData(roundId, answer, updatedAt, answeredInRound);
235+
}
236+
237+
/**
238+
* @notice Returns the latest round ID
239+
* @dev Falls back to extracting round ID from latestRoundData if latestRound() is not supported
240+
* @return The latest round ID
241+
*/
242+
function latestRound() external view override returns (uint256) {
243+
try priceFeed.latestRound() returns (uint256 round) {
244+
return round;
245+
} catch {
246+
// Fallback: extract round ID from latestRoundData
247+
(uint80 roundId, , , , ) = priceFeed.latestRoundData();
248+
return uint256(roundId);
249+
}
250+
}
251+
252+
/**
253+
* @notice Sets the fee recipient address
254+
* @param _feeRecipient The new fee recipient address
255+
*/
256+
function setFeeRecipient(address _feeRecipient) external onlyOwner {
257+
require(
258+
_feeRecipient != address(0),
259+
"ChainlinkOEVMorphoWrapper: fee recipient cannot be zero address"
260+
);
261+
262+
address oldFeeRecipient = feeRecipient;
263+
feeRecipient = _feeRecipient;
264+
265+
emit FeeRecipientChanged(oldFeeRecipient, _feeRecipient);
266+
}
267+
268+
/**
269+
* @notice Sets the fee multiplier for OEV fees
270+
* @param _feeMultiplier The new fee multiplier in basis points (must be <= MAX_BPS)
271+
*/
272+
function setFeeMultiplier(uint16 _feeMultiplier) external onlyOwner {
273+
require(
274+
_feeMultiplier <= MAX_BPS,
275+
"ChainlinkOEVMorphoWrapper: fee multiplier cannot be greater than MAX_BPS"
276+
);
277+
uint16 oldFeeMultiplier = feeMultiplier;
278+
feeMultiplier = _feeMultiplier;
279+
emit FeeMultiplierChanged(oldFeeMultiplier, _feeMultiplier);
280+
}
281+
282+
/// @notice Validate the round data from Chainlink
283+
/// @param roundId The round ID to validate
284+
/// @param answer The price to validate
285+
/// @param updatedAt The timestamp when the round was updated
286+
/// @param answeredInRound The round ID in which the answer was computed
287+
function _validateRoundData(
288+
uint80 roundId,
289+
int256 answer,
290+
uint256 updatedAt,
291+
uint80 answeredInRound
292+
) internal pure {
293+
require(answer > 0, "Chainlink price cannot be lower or equal to 0");
294+
require(updatedAt != 0, "Round is in incompleted state");
295+
require(answeredInRound >= roundId, "Stale price");
296+
}
297+
298+
/**
299+
* @notice Updates the cached round ID to allow early access to the latest price and executes a liquidation
300+
* @dev This function collects a fee from the caller, updates the cached price, and performs the liquidation on Morpho Blue
301+
* @param marketParams The Morpho market parameters identifying the market
302+
* @param borrower The address of the borrower to liquidate
303+
* @param repayAmount The amount of loan tokens to repay on behalf of the borrower
304+
* @param seizedAssets The amount of collateral assets to seize from the borrower
305+
*/
306+
function updatePriceEarlyAndLiquidate(
307+
MarketParams memory marketParams,
308+
address borrower,
309+
uint256 repayAmount,
310+
uint256 seizedAssets
311+
) external {
312+
// ensure the repay amount is greater than zero
313+
require(
314+
repayAmount > 0,
315+
"ChainlinkOEVMorphoWrapper: repay amount cannot be zero"
316+
);
317+
318+
// ensure the borrower is not the zero address
319+
require(
320+
borrower != address(0),
321+
"ChainlinkOEVMorphoWrapper: borrower cannot be zero address"
322+
);
323+
324+
// ensure the seized assets is greater than zero
325+
require(
326+
seizedAssets > 0,
327+
"ChainlinkOEVMorphoWrapper: seized assets cannot be zero"
328+
);
329+
330+
// get the loan token from market params
331+
EIP20Interface loanToken = EIP20Interface(marketParams.loanToken);
332+
333+
// get the collateral token from market params
334+
EIP20Interface collateralToken = EIP20Interface(
335+
marketParams.collateralToken
336+
);
337+
338+
// transfer the repay amount (loan tokens) from caller to this contract
339+
loanToken.transferFrom(msg.sender, address(this), repayAmount);
340+
341+
// get the latest round data
342+
(
343+
uint80 roundId,
344+
int256 answer,
345+
,
346+
uint256 updatedAt,
347+
uint80 answeredInRound
348+
) = priceFeed.latestRoundData();
349+
350+
// validate the round data
351+
_validateRoundData(roundId, answer, updatedAt, answeredInRound);
352+
353+
// update the cached round id
354+
cachedRoundId = roundId;
355+
356+
// approve Morpho Blue to spend the loan tokens for liquidation
357+
loanToken.approve(address(morphoBlue), repayAmount);
358+
359+
// liquidate the borrower on Morpho Blue
360+
// seizedAssets: amount of collateral to seize
361+
// repaidShares: 0 (means we use seizedAssets to determine liquidation amount)
362+
(uint256 actualSeizedAssets, uint256 actualRepaidAssets) = morphoBlue
363+
.liquidate(marketParams, borrower, seizedAssets, 0, "");
364+
365+
// calculate the protocol fee based on the seized collateral
366+
uint256 fee = (actualSeizedAssets * uint256(feeMultiplier)) / MAX_BPS;
367+
368+
// ensure the fee is greater than zero
369+
require(fee > 0, "ChainlinkOEVMorphoWrapper: fee cannot be zero");
370+
371+
// if the fee recipient is not set, use the owner as the recipient
372+
address recipient = feeRecipient == address(0) ? owner() : feeRecipient;
373+
374+
// transfer the protocol fee (in collateral tokens) to the recipient
375+
collateralToken.transfer(recipient, fee);
376+
377+
// transfer the remaining collateral tokens to the liquidator (caller)
378+
collateralToken.transfer(msg.sender, actualSeizedAssets - fee);
379+
380+
emit PriceUpdatedEarlyAndLiquidated(
381+
msg.sender,
382+
borrower,
383+
actualSeizedAssets,
384+
actualRepaidAssets,
385+
fee
386+
);
387+
}
388+
}

0 commit comments

Comments
 (0)