@@ -419,20 +419,20 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
419419 ///
420420 /// Settlement guard: the walk's per-fill checks run against a snapshot of Morpho's totals,
421421 /// and on a virtual-share boundary the settled totals can round the remaining debt such
422- /// that an accepted dust-sized fill leaves the LTV flat or higher (and repeated fills could
423- /// walk it past `liquidationLtv` into the proportional path). After the repay settles, the
424- /// position is re-quoted at the entry price and the call reverts unless the LTV strictly
425- /// decreased ({_checkLtvReduced}); with the entry LTV at or below `liquidationLtv`, a
426- /// strict decrease also keeps the settled LTV within the band's bound.
422+ /// that an accepted dust-sized fill leaves the LTV flat or higher (and, at the band's upper
423+ /// edge, tips the floored health check into the proportional path). The call reverts unless
424+ /// the fill strictly decreased the LTV ({_checkLtvStrictlyReduced}); with the entry LTV at or
425+ /// below `liquidationLtv`, a strict decrease also keeps the settled LTV within the band's
426+ /// bound. The guard runs before the repay on a frame derived from the fill amounts, so a
427+ /// liquidator callback cannot manipulate the state it reads.
427428 function _offerPreLiquidate (address borrower , uint256 seizedAssets , uint256 repaidShares , bytes calldata data )
428429 internal
429430 returns (uint256 , uint256 )
430431 {
431- // One oracle read for the whole call: the walk, the entry quote and the settlement guard all
432- // price collateral identically, so a mid-call price move (e.g. from the liquidator callback)
433- // cannot skew the entry/exit comparison.
432+ // One oracle read for the whole call: the walk and the settlement guard price collateral
433+ // identically, so a mid-call price move (e.g. from the liquidator callback) cannot skew the
434+ // entry/exit comparison.
434435 uint256 price = IOracle (_borrowPositionStorage ().marketParams.oracle).price ();
435- (uint256 entryDebtValue , uint256 entryCollateralValue ) = _positionLtvFrame (price);
436436
437437 // The walk reads market/position once (post-accrual, so exact) and applies all offer mutations
438438 // as effects before the repay below. Scoped in a helper to keep this function's stack shallow.
@@ -442,9 +442,15 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
442442 // gives liquidators a clear signal when the band is entered but nothing is fillable.
443443 if (totalSeized == 0 || totalDebtShares == 0 ) revert LibBorrowErrors.NoConsumableOffer ();
444444
445- uint256 repaidAssets = _settleOfferRepay (borrower, totalSeized, totalDebtShares, data);
445+ // Settlement guard, evaluated BEFORE the repay on the frame DERIVED from the fill amounts, so a
446+ // liquidator callback cannot move the state it reads. The Morpho `repay` withdraws the seized
447+ // collateral and then hands control to `onPreLiquidate`; since `repay` and `supplyCollateral`
448+ // are permissionless, a post-repay live re-quote could be neutralised by a callback that drains
449+ // the position through the proportional path and re-supplies a dust atom, letting a fill that
450+ // does not de-risk the position stand. The derived frame is immune to that.
451+ _checkLtvStrictlyReduced (price, totalSeized, totalDebtShares);
446452
447- _checkLtvReduced (price, entryDebtValue, entryCollateralValue );
453+ uint256 repaidAssets = _settleOfferRepay (borrower, totalSeized, totalDebtShares, data );
448454
449455 return (totalSeized, repaidAssets);
450456 }
@@ -460,30 +466,44 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
460466 (repaidAssets,) = MORPHO.repay (_borrowPositionStorage ().marketParams, 0 , totalDebtShares, borrower, callbackData);
461467 }
462468
463- /// @dev Quotes the position's debt and collateral values at `price` from the current Morpho
464- /// state (post-accrual totals; debt rounds up, collateral rounds down, matching
465- /// {_isHealthy}). Called by {_offerPreLiquidate} before the consume and after the repay
466- /// settles, so the two frames differ only by the fill's real effect on Morpho's totals.
467- function _positionLtvFrame (uint256 price ) internal view returns (uint256 debtValue , uint256 collateralValue ) {
469+ /// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the fill strictly lowers the position LTV:
470+ /// `exitDebt / exitCollateral < entryDebt / entryCollateral`, cross-multiplied to
471+ /// `exitDebt * entryCollateral < entryDebt * exitCollateral` so the comparison is exact.
472+ ///
473+ /// The exit frame is DERIVED from the fill amounts against the current (pre-repay) Morpho
474+ /// snapshot rather than re-read live after the repay, so it measures the fill alone and no
475+ /// liquidator callback can move it. It reproduces Morpho's shares-mode `repay` settlement
476+ /// exactly: the repaid assets round up, the market totals drop by (`repaidAssets`,
477+ /// `totalDebtShares`) with a saturating subtraction on the assets (Morpho's `repaidAssets`
478+ /// may exceed `totalBorrowAssets` by one), and the remaining debt rounds up on the reduced
479+ /// totals. Interest was accrued at entry and the in-flight repay re-accrues with zero elapsed
480+ /// time, so these totals equal the settled ones; the derived frame is therefore the exact
481+ /// state a post-repay quote would read absent a callback (debt rounds up, collateral rounds
482+ /// down, matching {_isHealthy}).
483+ ///
484+ /// The product goes through `fullMulDiv`'s 512-bit intermediate rather than a plain
485+ /// multiplication: the collateral values are price-scaled (`price` is an unbounded oracle
486+ /// value at 1e36 scale) and can exceed 128 bits, so the direct product can overflow 256 bits
487+ /// and checked multiplication would turn a legitimate fill into a spurious revert. Flooring
488+ /// keeps it exact (`floor(a * b / d) >= c <=> a * b >= c * d` for integers). `entryDebtValue`
489+ /// and `totalDebtShares` are non-zero in the band (the position has debt; the fill repaid at
490+ /// least one share).
491+ function _checkLtvStrictlyReduced (uint256 price , uint256 totalSeized , uint256 totalDebtShares ) internal view {
468492 Id _marketId = _borrowPositionStorage ().marketId;
469493 Position memory position = MORPHO.position (_marketId, address (this ));
470494 Market memory market = MORPHO.market (_marketId);
471- debtValue =
472- uint256 (position.borrowShares).toAssetsUp (uint256 (market.totalBorrowAssets), uint256 (market.totalBorrowShares));
473- collateralValue = uint256 (position.collateral).mulDiv (price, ORACLE_PRICE_SCALE);
474- }
495+ uint256 totalBorrowAssets = uint256 (market.totalBorrowAssets);
496+ uint256 totalBorrowShares = uint256 (market.totalBorrowShares);
497+
498+ uint256 entryDebtValue = uint256 (position.borrowShares).toAssetsUp (totalBorrowAssets, totalBorrowShares);
499+ uint256 entryCollateralValue = uint256 (position.collateral).mulDiv (price, ORACLE_PRICE_SCALE);
500+
501+ // Settled frame derived from the fill (matches Morpho's shares-mode repay rounding).
502+ uint256 repaidAssets = totalDebtShares.toAssetsUp (totalBorrowAssets, totalBorrowShares);
503+ uint256 exitDebtValue = (uint256 (position.borrowShares) - totalDebtShares)
504+ .toAssetsUp (totalBorrowAssets.zeroFloorSub (repaidAssets), totalBorrowShares - totalDebtShares);
505+ uint256 exitCollateralValue = (uint256 (position.collateral) - totalSeized).mulDiv (price, ORACLE_PRICE_SCALE);
475506
476- /// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the settled position's LTV is strictly
477- /// below the entry frame's: `exitDebt / exitCollateral < entryDebt / entryCollateral`,
478- /// cross-multiplied to `exitDebt * entryCollateral < entryDebt * exitCollateral` so the
479- /// comparison is exact. The product goes through `fullMulDiv`'s 512-bit intermediate
480- /// rather than a plain multiplication: the collateral values are price-scaled (`price` is
481- /// an unbounded oracle value at 1e36 scale) and can exceed 128 bits, so the direct product
482- /// can overflow 256 bits and checked multiplication would turn a legitimate fill into a
483- /// spurious revert. Flooring keeps it exact (`floor(a * b / d) >= c <=> a * b >= c * d`
484- /// for integers). `entryDebtValue > 0` in the band (the position has debt, rounded up).
485- function _checkLtvReduced (uint256 price , uint256 entryDebtValue , uint256 entryCollateralValue ) internal view {
486- (uint256 exitDebtValue , uint256 exitCollateralValue ) = _positionLtvFrame (price);
487507 if (FixedPointMathLib.fullMulDiv (exitDebtValue, entryCollateralValue, entryDebtValue) >= exitCollateralValue) {
488508 revert LibBorrowErrors.LtvNotReduced ();
489509 }
0 commit comments