This guide walks through building a liquidation bot that uses Moonwell's OEV (Oracle Extractable Value) system to capture liquidation profit. Instead of competing with MEV searchers, you route liquidations through the OEV wrapper to access fresh Chainlink prices before everyone else.
How OEV Liquidation Works
Chainlink posts a new price update
The OEV wrapper delays the new price by ~10 seconds
During this window, only the updatePriceEarlyAndLiquidate() function can use the fresh price
Your bot calls this function, which unlocks the price, executes the liquidation, and splits the profit
Liquidation profits are split between you and the protocol based on liquidatorFeeBps (currently 40% to liquidator)
Use the Comptroller to check which accounts are underwater.
Comptroller comptroller =Comptroller(0x...);// Returns (error, liquidity, shortfall) - all in USD with 18 decimals(uint err,uint liquidity,uint shortfall)= comptroller.getAccountLiquidity(borrowerAddress);// shortfall > 0 means the account is liquidatable
In practice, you'll monitor on-chain events or index account positions off-chain. When a new Chainlink round is posted and an account becomes underwater at the new price, that's your OEV opportunity - you have ~10 seconds before the price becomes public.
Step 2: Calculate Repay Amount
The maximum you can repay in a single liquidation is limited by the close factor (typically 50%).
Step 3: Execute the OEV Liquidation
Deploy a contract that calls updatePriceEarlyAndLiquidate() on the OEV wrapper. The wrapper handles everything - price update, liquidation execution, and profit splitting.
Step 4: Profit
After a successful liquidation, your contract receives mTokens representing your share of the seized collateral. You get your full repayment back plus a share of the profit based on liquidatorFeeBps (configurable, currently 4000 = 40%):
Amount
Repayment
1,000 USDC
Collateral seized (10% liquidation incentive)
1,100 USDC
Gross profit
100 USDC
Your bonus (40% of profit)
40 USDC worth of mTokens
Your total
1,040 USDC worth of mTokens
Protocol share (60% of profit)
60 USDC worth of mTokens
You can hold the mTokens (they earn interest) or redeem them for the underlying collateral token.
Morpho Market Liquidations
The Morpho OEV wrapper (ChainlinkOEVMorphoWrapper) works differently from the core wrapper:
Core Markets
Morpho Markets
Collateral received
mTokens
Underlying tokens
Market identification
mToken addresses
MarketParams struct
Slippage protection
Fixed repay amount
maxRepayAmount parameter
You specify
Repay amount
Seized collateral amount
The Morpho wrapper's oracle must have the OEV wrapper set as BASE_FEED_1. The contract verifies this and reverts if the oracle isn't configured correctly.
Use the right wrapper. Each OEV wrapper is tied to a specific collateral asset's Chainlink feed. The contract verifies the feed matches and reverts if you use the wrong wrapper. See Core Markets - Deployed Contracts for wrapper addresses.
The ~10 second window is competitive. Multiple liquidators may target the same opportunity. Your transaction needs to land before the delay expires, at which point the price becomes public and standard liquidation bots can compete.
Repay amount cannot be zero or type(uint).max. The wrapper rejects zero amounts directly. Passing type(uint).max is rejected by the underlying liquidateBorrow call, which causes the wrapper to revert with "ChainlinkOEVWrapper: liquidation failed". Use borrowBalanceCurrent() and closeFactorMantissa() to calculate a valid amount.
Error handling. If the underlying liquidateBorrow fails (e.g. the account isn't actually underwater at the fresh price), the wrapper reverts with "ChainlinkOEVWrapper: liquidation failed".
MErc20 mTokenLoan = MErc20(0x...); // The market the borrower owes
// Get the borrower's current debt in this market
uint borrowBalance = mTokenLoan.borrowBalanceCurrent(borrowerAddress);
// Close factor is a mantissa (e.g. 0.5e18 = 50%)
uint closeFactor = comptroller.closeFactorMantissa();
// Maximum repay amount
uint maxRepay = borrowBalance * closeFactor / 1e18;
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IChainlinkOEVWrapper {
function updatePriceEarlyAndLiquidate(
address borrower,
uint256 repayAmount,
address mTokenCollateral,
address mTokenLoan
) external;
}
interface IMToken {
function underlying() external view returns (address);
}
contract OEVLiquidator {
address public owner;
constructor() {
owner = msg.sender;
}
/// @notice Execute an OEV liquidation
/// @param wrapper The ChainlinkOEVWrapper for the collateral asset
/// @param borrower The underwater account to liquidate
/// @param repayAmount Amount of loan tokens to repay
/// @param mTokenCollateral The mToken market for collateral being seized
/// @param mTokenLoan The mToken market for the loan being repaid
function liquidate(
address wrapper,
address borrower,
uint256 repayAmount,
address mTokenCollateral,
address mTokenLoan
) external {
require(msg.sender == owner, "not owner");
// 1. Get the loan token
address loanToken = IMToken(mTokenLoan).underlying();
// 2. Transfer loan tokens from caller
IERC20(loanToken).transferFrom(msg.sender, address(this), repayAmount);
// 3. Approve wrapper to spend loan tokens
IERC20(loanToken).approve(wrapper, repayAmount);
// 4. Execute OEV liquidation - unlocks fresh price + liquidates + splits profit
IChainlinkOEVWrapper(wrapper).updatePriceEarlyAndLiquidate(
borrower,
repayAmount,
mTokenCollateral,
mTokenLoan
);
// 5. Seized mTokens (your share) are now in this contract
// Transfer them to the caller or redeem for underlying
}
/// @notice Withdraw any tokens from this contract
function sweep(address token, address to) external {
require(msg.sender == owner, "not owner");
IERC20(token).transfer(to, IERC20(token).balanceOf(address(this)));
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
struct MarketParams {
address loanToken;
address collateralToken;
address oracle;
address irm;
uint256 lltv;
}
interface IChainlinkOEVMorphoWrapper {
function updatePriceEarlyAndLiquidate(
MarketParams memory marketParams,
address borrower,
uint256 seizedAssets,
uint256 maxRepayAmount
) external;
}
contract MorphoOEVLiquidator {
address public owner;
constructor() {
owner = msg.sender;
}
function liquidate(
address wrapper,
MarketParams calldata marketParams,
address borrower,
uint256 seizedAssets,
uint256 maxRepayAmount
) external {
require(msg.sender == owner, "not owner");
address loanToken = marketParams.loanToken;
// 1. Transfer max loan tokens from caller
IERC20(loanToken).transferFrom(msg.sender, address(this), maxRepayAmount);
// 2. Approve wrapper
IERC20(loanToken).approve(wrapper, maxRepayAmount);
// 3. Execute - excess loan tokens are returned automatically
IChainlinkOEVMorphoWrapper(wrapper).updatePriceEarlyAndLiquidate(
marketParams,
borrower,
seizedAssets,
maxRepayAmount
);
// 4. Collateral tokens (not mTokens) are now in this contract
IERC20(marketParams.collateralToken).transfer(
msg.sender,
IERC20(marketParams.collateralToken).balanceOf(address(this))
);
// 5. Return any remaining loan tokens
uint256 remaining = IERC20(loanToken).balanceOf(address(this));
if (remaining > 0) {
IERC20(loanToken).transfer(msg.sender, remaining);
}
}
}