MidnightVaultExecutor::onLiquidate forces liquidators to provide callback data and never refunds leftover loan tokens
Summary
MidnightVaultExecutor::onLiquidate() always sends the redeemed loan tokens to the liquidator and invokes their callback only when callbackData.length > 0. A liquidator without a callback contract has no way to push the funds back to the executor for Midnight to pull the debt, so the liquidation reverts. The sibling onRepay already supports the no-callback path. Leftover loan tokens are also never swept to the liquidator on this path.
Vulnerability Detail
In onLiquidate, the redeemed underlying is unconditionally sent to the liquidator:
uint256 redeemed = IERC4626(vault).redeem(seizedShares, liquidator, address(this));
if (minSharePriceE27 != 0 && UtilsLib.mulDivDown(redeemed, RAY, seizedShares) < minSharePriceE27) {
revert SlippageExceeded();
}
if (callbackData.length > 0) {
require(
ILiquidateCallback(liquidator).onLiquidate(...) == CALLBACK_SUCCESS,
IMidnight.WrongLiquidateCallbackReturnValue()
);
}After onLiquidate returns, Midnight pulls repaidUnits of loan token from the executor. With callbackData.length == 0, no one has transferred the loan token back to the executor, so the pull reverts and the liquidation fails. The only liquidators that work are ones that implement ILiquidateCallback.
Compare with onRepay, which routes the redeem recipient based on whether callback data is present:
address recipient = callbackData.length > 0 ? caller : address(this);
uint256 redeemed = IERC4626(vault).redeem(sharesToWithdraw, recipient, address(this));Additionally, repayAndWithdrawCollateral sweeps leftover loan tokens back to the caller, but liquidateAndRedeem and onLiquidate do not. Any loan-token dust left on the executor after the liquidation is forfeited and may be swept by the next caller.
Impact
EOA liquidators and any liquidator without a Midnight-compatible callback contract are DoSed from liquidating vault-share collateral through the executor. Any leftover loan tokens on the executor after a successful liquidation are forfeited.
Code Snippet
Tool Used
Manual Review
Recommendation
Mirror the onRepay pattern: redeem to address(this) when there is no callback data, and only forward to the liquidator inside the callback branch. After the liquidation completes in liquidateAndRedeem, transfer the redeemed loan token and any dust to the liquidator:
address recipient = callbackData.length > 0 ? liquidator : address(this);
uint256 redeemed = IERC4626(vault).redeem(seizedShares, recipient, address(this));And in liquidateAndRedeem, sweep the remaining loan-token balance to msg.sender after the MORPHO_MIDNIGHT.liquidate call returns.