Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/borrow/BorrowOffersRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ contract BorrowOffersRegistry is IBorrowOffersRegistry, Initializable, OwnableRo
}

/// @inheritdoc IBorrowOffersRegistry
function checkCanRevokeOffer(address account) external view override {
_checkHasRolesOrOwner(account, GUARDIAN_ROLE);
function canRevokeOffer(address account) external view override returns (bool) {
return hasAnyRole(account, GUARDIAN_ROLE) || account == owner();
}

/// @dev Reverts with Solady's `Unauthorized` unless `account` holds any of `roles` or is the
Expand Down
121 changes: 97 additions & 24 deletions src/borrow/MorphoBorrowPosition.sol
Original file line number Diff line number Diff line change
Expand Up @@ -416,41 +416,116 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
/// `_isHealthy(LLTV)` holds; every fill strictly lowers LTV, so the post-withdraw state that
/// Morpho re-checks inside `withdrawCollateral` retains margin. The conservative rounding in
/// the fill math is load-bearing at the knife edge.
///
/// Settlement guard: the walk's per-fill checks run against a snapshot of Morpho's totals,
/// and on a virtual-share boundary the settled totals can round the remaining debt such
/// that an accepted dust-sized fill leaves the LTV flat or higher (and, at the band's upper
/// edge, tips the floored health check into the proportional path). The call reverts unless
/// the fill strictly decreased the LTV ({_checkLtvStrictlyReduced}); with the entry LTV at or
/// below `liquidationLtv`, a strict decrease also keeps the settled LTV within the band's
/// bound. The guard runs before the repay on a frame derived from the fill amounts, so a
/// liquidator callback cannot manipulate the state it reads.
function _offerPreLiquidate(address borrower, uint256 seizedAssets, uint256 repaidShares, bytes calldata data)
internal
returns (uint256, uint256)
{
// One oracle read for the whole call: the walk and the settlement guard price collateral
// identically, so a mid-call price move (e.g. from the liquidator callback) cannot skew the
// entry/exit comparison.
uint256 price = IOracle(_borrowPositionStorage().marketParams.oracle).price();

// The walk reads market/position once (post-accrual, so exact) and applies all offer mutations
// as effects before the repay below. Scoped in a helper to keep this function's stack shallow.
(uint256 totalSeized, uint256 totalDebtShares) = _consumeOffers(borrower, seizedAssets, repaidShares);
(uint256 totalSeized, uint256 totalDebtShares) = _consumeOffers(borrower, seizedAssets, repaidShares, price);

// Mandatory: a zero-amount repay would hit Morpho's exactlyOneZero (INCONSISTENT_INPUT); this
// gives liquidators a clear signal when the band is entered but nothing is fillable.
if (totalSeized == 0 || totalDebtShares == 0) revert LibBorrowErrors.NoConsumableOffer();

bytes memory callbackData = abi.encode(totalSeized, borrower, msg.sender, data);
(uint256 repaidAssets,) =
MORPHO.repay(_borrowPositionStorage().marketParams, 0, totalDebtShares, borrower, callbackData);
// Settlement guard, evaluated BEFORE the repay on the frame DERIVED from the fill amounts, so a
// liquidator callback cannot move the state it reads. The Morpho `repay` withdraws the seized
// collateral and then hands control to `onPreLiquidate`; since `repay` and `supplyCollateral`
// are permissionless, a post-repay live re-quote could be neutralised by a callback that drains
// the position through the proportional path and re-supplies a dust atom, letting a fill that
// does not de-risk the position stand. The derived frame is immune to that.
_checkLtvStrictlyReduced(price, totalSeized, totalDebtShares);

uint256 repaidAssets = _settleOfferRepay(borrower, totalSeized, totalDebtShares, data);

return (totalSeized, repaidAssets);
}

/// @dev Reads the post-accrual market/position snapshot and runs the offer consume walk. Returns
/// @dev Performs the single shares-mode Morpho repay that settles a band pre-liquidation.
/// Scoped out of {_offerPreLiquidate} so the callback bytes do not coexist on its stack
/// with the entry-frame values (this codebase compiles without the via-IR pipeline).
function _settleOfferRepay(address borrower, uint256 totalSeized, uint256 totalDebtShares, bytes calldata data)
internal
returns (uint256 repaidAssets)
{
bytes memory callbackData = abi.encode(totalSeized, borrower, msg.sender, data);
(repaidAssets,) = MORPHO.repay(_borrowPositionStorage().marketParams, 0, totalDebtShares, borrower, callbackData);
}

/// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the fill strictly lowers the position LTV:
/// `exitDebt / exitCollateral < entryDebt / entryCollateral`, cross-multiplied to
/// `exitDebt * entryCollateral < entryDebt * exitCollateral` so the comparison is exact.
///
/// The exit frame is DERIVED from the fill amounts against the current (pre-repay) Morpho
/// snapshot rather than re-read live after the repay, so it measures the fill alone and no
/// liquidator callback can move it. It reproduces Morpho's shares-mode `repay` settlement
/// exactly: the repaid assets round up, the market totals drop by (`repaidAssets`,
/// `totalDebtShares`) with a saturating subtraction on the assets (Morpho's `repaidAssets`
/// may exceed `totalBorrowAssets` by one), and the remaining debt rounds up on the reduced
/// totals. Interest was accrued at entry and the in-flight repay re-accrues with zero elapsed
/// time, so these totals equal the settled ones; the derived frame is therefore the exact
/// state a post-repay quote would read absent a callback (debt rounds up, collateral rounds
/// down, matching {_isHealthy}).
///
/// The product goes through `fullMulDiv`'s 512-bit intermediate rather than a plain
/// multiplication: the collateral values are price-scaled (`price` is an unbounded oracle
/// value at 1e36 scale) and can exceed 128 bits, so the direct product can overflow 256 bits
/// and checked multiplication would turn a legitimate fill into a spurious revert. Flooring
/// keeps it exact (`floor(a * b / d) >= c <=> a * b >= c * d` for integers). `entryDebtValue`
/// and `totalDebtShares` are non-zero in the band (the position has debt; the fill repaid at
/// least one share).
function _checkLtvStrictlyReduced(uint256 price, uint256 totalSeized, uint256 totalDebtShares) internal view {
Id _marketId = _borrowPositionStorage().marketId;
Position memory position = MORPHO.position(_marketId, address(this));
Market memory market = MORPHO.market(_marketId);
uint256 totalBorrowAssets = uint256(market.totalBorrowAssets);
uint256 totalBorrowShares = uint256(market.totalBorrowShares);

uint256 entryDebtValue = uint256(position.borrowShares).toAssetsUp(totalBorrowAssets, totalBorrowShares);
uint256 entryCollateralValue = uint256(position.collateral).mulDiv(price, ORACLE_PRICE_SCALE);

// Settled frame derived from the fill (matches Morpho's shares-mode repay rounding).
uint256 repaidAssets = totalDebtShares.toAssetsUp(totalBorrowAssets, totalBorrowShares);
uint256 exitDebtValue = (uint256(position.borrowShares) - totalDebtShares)
.toAssetsUp(totalBorrowAssets.zeroFloorSub(repaidAssets), totalBorrowShares - totalDebtShares);
uint256 exitCollateralValue = (uint256(position.collateral) - totalSeized).mulDiv(price, ORACLE_PRICE_SCALE);

if (FixedPointMathLib.fullMulDiv(exitDebtValue, entryCollateralValue, entryDebtValue) >= exitCollateralValue) {
revert LibBorrowErrors.LtvNotReduced();
}
}

/// @dev Reads the post-accrual market/position snapshot and runs the offer consume walk at the
/// caller-provided oracle `price` (read once per call in {_offerPreLiquidate}). Returns
/// the aggregate `(totalSeized, totalDebtShares)`. Split out of {_offerPreLiquidate} so the
/// large `Market`/`Position` memory structs do not coexist on the stack with the repay.
function _consumeOffers(address borrower, uint256 seizedAssets, uint256 repaidShares)
function _consumeOffers(address borrower, uint256 seizedAssets, uint256 repaidShares, uint256 price)
internal
returns (uint256 totalSeized, uint256 totalDebtShares)
{
BorrowPositionStorage storage _storage = _borrowPositionStorage();
Position memory position = MORPHO.position(_storage.marketId, borrower);
Market memory market = MORPHO.market(_storage.marketId);
Id _marketId = _borrowPositionStorage().marketId;
Position memory position = MORPHO.position(_marketId, borrower);
Market memory market = MORPHO.market(_marketId);

LibBorrowOffers.BorrowOffersStorage storage o = LibBorrowOffers.borrowOffersStorage();
LibBorrowOffers.ConsumeInput memory input = LibBorrowOffers.ConsumeInput({
seizedTarget: seizedAssets,
repaidSharesTarget: repaidShares,
price: IOracle(_storage.marketParams.oracle).price(),
price: price,
totalBorrowAssets: uint256(market.totalBorrowAssets),
totalBorrowShares: uint256(market.totalBorrowShares),
positionCollateral: uint256(position.collateral),
Expand Down Expand Up @@ -890,11 +965,10 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
/// Both gates evaluate {LibBorrowOffers.isProfitableAboveBonusFloor} (the single
/// implementation of the floor's inequality and rounding); the strict profitability
/// comparison is split out first only to pick the right error. They are proposal-time
/// filters against the state now; the same
/// checks are re-evaluated per fill at consume time (see {LibBorrowOffers._priceAction}), so
/// a live offer whose bonus later drifts below the floor via price or accrued-interest
/// movement is skipped by the consume walk rather than dragging the band's profitability
/// down (a guardian can also revoke it).
/// filters against the state now; the same checks are re-evaluated per offer at consume
/// time on its whole remaining amounts, so a live offer whose bonus later drifts below the
/// floor via price or accrued-interest movement is skipped by the consume walk rather than
/// dragging the band's profitability down (a guardian can also revoke it).
function _checkOfferProfitable(uint128 collateral, uint128 debtShares, uint16 minBonusBps) internal view {
BorrowPositionStorage storage _storage = _borrowPositionStorage();
(,, uint256 totalBorrowAssets, uint256 totalBorrowShares) =
Expand All @@ -908,20 +982,19 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
}

/// @inheritdoc IBorrowOffers
/// @dev Gated by {IBorrowOffersRegistry.checkCanRevokeOffer} (a guardian, or the registry
/// owner). Removes each id from the offer book (the guardian veto inside the timelock
/// window, and the kill switch for a bad standing offer at any later stage). Self-revoke
/// by a plain proposer is deliberately not offered: revoke stays with the guardians and
/// the registry owner, who can already remove any offer.
/// @dev Authorization is per offer: the recorded proposer may revoke its own offer at any time
/// (a mistaken offer must not stand just because no guardian reacted before `activeAt`);
/// any other caller needs the registry's revoke power
/// ({IBorrowOffersRegistry.canRevokeOffer}: a guardian, or the registry owner), read once
/// for the whole batch and enforced per offer inside {LibBorrowOffers.removeOffer}. An
/// empty batch is a no-op.
function revokeOffers(uint8[] calldata ids) external override {
OFFERS_REGISTRY.checkCanRevokeOffer(msg.sender);
LibBorrowOffers.BorrowOffersStorage storage o = LibBorrowOffers.borrowOffersStorage();
// Cache the calldata length once, and each id into a stack variable, so the loop performs a
// single CALLDATALOAD per iteration instead of re-reading `ids.length` and `ids[i]` twice each.
bool isGuardian = OFFERS_REGISTRY.canRevokeOffer(msg.sender);
uint256 length = ids.length;
for (uint256 i; i < length; ++i) {
uint8 id = ids[i];
o.removeOffer(id);
o.removeOffer(id, msg.sender, isGuardian);
emit OfferRevoked(id, msg.sender);
}
}
Expand Down
24 changes: 17 additions & 7 deletions src/interfaces/borrow/IBorrowOffers.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ pragma solidity ^0.8.22;
/// set <=> the slab slot holds a live offer), not by any field of this struct; a freed slot is
/// fully zeroed. A live offer always has a non-zero proposer and both remaining amounts > 0.
struct Offer {
/// @dev The account that posted the offer (accountability; also used by views).
/// @dev The account that posted the offer. May revoke its own offer at any time (also used by
/// views and for accountability).
address proposer;
/// @dev Absolute timestamp at which the offer becomes consumable. Fixed at proposal time as
/// `proposalTime + effectiveTimelockAtProposal`, so a later timelock change cannot re-time
Expand Down Expand Up @@ -63,7 +64,8 @@ interface IBorrowOffers {
uint40 expiresAt
);

/// @notice Emitted when an offer is revoked (guardian veto / admin / owner kill switch).
/// @notice Emitted when an offer is revoked (proposer self-revoke, guardian veto, or the
/// registry owner's kill switch).
/// @param id The slab id of the revoked offer.
/// @param by The caller that revoked it.
event OfferRevoked(uint8 indexed id, address indexed by);
Expand All @@ -74,7 +76,8 @@ interface IBorrowOffers {
/// @param id The slab id of the consumed offer.
/// @param collateralFilled The collateral tokens seized from this offer in this chunk.
/// @param debtSharesFilled The borrow shares repaid against this offer in this chunk.
/// @param exhausted True if the offer was fully consumed (and removed) by this chunk.
/// @param exhausted True if this chunk removed the offer: a side was fully consumed, or the
/// dust remainder was no longer strictly profitable (unusable at any bonus floor).
event OfferConsumed(uint8 indexed id, uint128 collateralFilled, uint128 debtSharesFilled, bool exhausted);

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
Expand All @@ -92,9 +95,13 @@ interface IBorrowOffers {
/// @return id The slab id assigned to the new offer.
function proposeOffer(uint128 collateral, uint128 debtShares, uint40 expiresAt) external returns (uint8 id);

/// @notice Revokes one or more offers (batch). Gated by
/// {IBorrowOffersRegistry.checkCanRevokeOffer} (a guardian, or the registry owner).
/// @dev Reverts {LibBorrowErrors.OfferNotFound} if any id is not a currently-live offer.
/// @notice Revokes one or more offers (batch). Authorization is per offer: the recorded
/// proposer may revoke its own offer at any time; any other caller needs the registry's
/// revoke power ({IBorrowOffersRegistry.canRevokeOffer}: a guardian, or the registry
/// owner).
/// @dev Reverts {LibBorrowErrors.OfferNotFound} if any id is not a currently-live offer, and
/// {LibBorrowErrors.Unauthorized} if any offer in the batch belongs to another proposer
/// while the caller lacks the registry's revoke power.
/// @param ids The slab ids to revoke.
function revokeOffers(uint8[] calldata ids) external;

Expand All @@ -116,11 +123,14 @@ interface IBorrowOffers {
/// profitable and within the max price at the current position LTV).
/// @dev Approximate helper: it evaluates the offer against the current position state in
/// isolation and does not account for ordering or the cumulative LTV change of consuming
/// earlier offers. The binding checks are the per-fill checks inside the consume walk.
/// earlier offers. The binding checks run inside the consume walk (profitability and the
/// bonus floor per offer, strict de-risking per fill).
function isConsumable(uint8 id) external view returns (bool);

/// @notice Off-chain helper that simulates a band consume for the given target without mutating
/// state.
/// @dev Simulates the walk only: the post-settlement LTV guard runs solely on the real
/// `preLiquidate` call, so a dust-sized quote from this view can still revert there.
/// @param seizedAssets The collateral-seize target (pass 0 to use `repaidShares`).
/// @param repaidShares The debt-share-repay target (pass 0 to use `seizedAssets`).
/// @return seized The total collateral that would be seized.
Expand Down
12 changes: 7 additions & 5 deletions src/interfaces/borrow/IBorrowOffersRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ interface IBorrowOffersRegistry {
/// a role, so an ownership handover never leaves the new owner without powers.
function checkCanCreateOffer(address account) external view;

/// @notice Reverts unless `account` may revoke offers (a guardian, or the registry owner): the
/// veto power inside the timelock window, and the kill switch for a bad standing offer.
/// @dev The authorization check used by {MorphoBorrowPosition.revokeOffers}; reverts with
/// Solady's `Unauthorized()`.
function checkCanRevokeOffer(address account) external view;
/// @notice Returns whether `account` may revoke any offer regardless of its proposer (a
/// guardian, or the registry owner): the veto power inside the timelock window, and the
/// kill switch for a bad standing offer.
/// @dev Read once per batch by {MorphoBorrowPosition.revokeOffers}, which enforces the
/// per-offer proposer check itself for callers without this power. A boolean read rather
/// than a reverting check: a proposer revoking its own offer is authorized without it.
function canRevokeOffer(address account) external view returns (bool);

/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
/* CONFIGURATION */
Expand Down
Loading