Skip to content

Commit 1783525

Browse files
committed
refactor: per-offer revoke auth via registry boolean read, hoist marketId, silence cast lints
Review follow-ups on the offer lifecycle hardening: - revokeOffers resolves the registry guardian/owner power once as a boolean (canRevokeOffer replaces the reverting checkCanRevokeOffer) and the per-offer proposer check moves into LibBorrowOffers.removeOffer; the Unauthorized error keeps Solady's selector so external behavior is unchanged - _positionLtvFrame and _consumeOffers read marketId from storage once - _checkLtvReduced NatSpec records why the cross-multiplication needs fullMulDiv's 512-bit intermediate (price-scaled values can overflow 256 bits) - forge 1.5.1 unsafe-typecast lints in LibBorrowOffers silenced with justified disable comments - new tests: empty revoke batch no-op, guardian revoke of an active offer, per-offer OfferRevoked events in a batch
1 parent 6689a8e commit 1783525

8 files changed

Lines changed: 123 additions & 51 deletions

File tree

‎src/borrow/BorrowOffersRegistry.sol‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ contract BorrowOffersRegistry is IBorrowOffersRegistry, Initializable, OwnableRo
171171
}
172172

173173
/// @inheritdoc IBorrowOffersRegistry
174-
function checkCanRevokeOffer(address account) external view override {
175-
_checkHasRolesOrOwner(account, GUARDIAN_ROLE);
174+
function canRevokeOffer(address account) external view override returns (bool) {
175+
return hasAnyRole(account, GUARDIAN_ROLE) || account == owner();
176176
}
177177

178178
/// @dev Reverts with Solady's `Unauthorized` unless `account` holds any of `roles` or is the

‎src/borrow/MorphoBorrowPosition.sol‎

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -465,19 +465,23 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
465465
/// {_isHealthy}). Called by {_offerPreLiquidate} before the consume and after the repay
466466
/// settles, so the two frames differ only by the fill's real effect on Morpho's totals.
467467
function _positionLtvFrame(uint256 price) internal view returns (uint256 debtValue, uint256 collateralValue) {
468-
BorrowPositionStorage storage _storage = _borrowPositionStorage();
469-
Position memory position = MORPHO.position(_storage.marketId, address(this));
470-
Market memory market = MORPHO.market(_storage.marketId);
468+
Id _marketId = _borrowPositionStorage().marketId;
469+
Position memory position = MORPHO.position(_marketId, address(this));
470+
Market memory market = MORPHO.market(_marketId);
471471
debtValue =
472472
uint256(position.borrowShares).toAssetsUp(uint256(market.totalBorrowAssets), uint256(market.totalBorrowShares));
473473
collateralValue = uint256(position.collateral).mulDiv(price, ORACLE_PRICE_SCALE);
474474
}
475475

476476
/// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the settled position's LTV is strictly
477477
/// below the entry frame's: `exitDebt / exitCollateral < entryDebt / entryCollateral`,
478-
/// compared exactly by cross-multiplication (`fullMulDiv` floors, so
479-
/// `floor(a / d) < y <=> a < y * d` makes the comparison exact). `entryDebtValue > 0` in
480-
/// the band (the position has debt, rounded up).
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).
481485
function _checkLtvReduced(uint256 price, uint256 entryDebtValue, uint256 entryCollateralValue) internal view {
482486
(uint256 exitDebtValue, uint256 exitCollateralValue) = _positionLtvFrame(price);
483487
if (FixedPointMathLib.fullMulDiv(exitDebtValue, entryCollateralValue, entryDebtValue) >= exitCollateralValue) {
@@ -493,9 +497,9 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
493497
internal
494498
returns (uint256 totalSeized, uint256 totalDebtShares)
495499
{
496-
BorrowPositionStorage storage _storage = _borrowPositionStorage();
497-
Position memory position = MORPHO.position(_storage.marketId, borrower);
498-
Market memory market = MORPHO.market(_storage.marketId);
500+
Id _marketId = _borrowPositionStorage().marketId;
501+
Position memory position = MORPHO.position(_marketId, borrower);
502+
Market memory market = MORPHO.market(_marketId);
499503

500504
LibBorrowOffers.BorrowOffersStorage storage o = LibBorrowOffers.borrowOffersStorage();
501505
LibBorrowOffers.ConsumeInput memory input = LibBorrowOffers.ConsumeInput({
@@ -960,25 +964,17 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
960964
/// @inheritdoc IBorrowOffers
961965
/// @dev Authorization is per offer: the recorded proposer may revoke its own offer at any time
962966
/// (a mistaken offer must not stand just because no guardian reacted before `activeAt`);
963-
/// any other caller must pass {IBorrowOffersRegistry.checkCanRevokeOffer} (a guardian, or
964-
/// the registry owner), the veto inside the timelock window and the kill switch for a bad
965-
/// standing offer at any later stage. Liveness is checked before the proposer read so an
966-
/// unknown id reverts {LibBorrowErrors.OfferNotFound} for every caller; an empty batch
967-
/// performs no authorization check and is a no-op.
967+
/// any other caller needs the registry's revoke power
968+
/// ({IBorrowOffersRegistry.canRevokeOffer}: a guardian, or the registry owner), read once
969+
/// for the whole batch and enforced per offer inside {LibBorrowOffers.removeOffer}. An
970+
/// empty batch is a no-op.
968971
function revokeOffers(uint8[] calldata ids) external override {
969972
LibBorrowOffers.BorrowOffersStorage storage o = LibBorrowOffers.borrowOffersStorage();
970-
bool checkedCanRevokeAny; // the registry guardian/owner check passes at most once
971-
// Cache the calldata length once, and each id into a stack variable, so the loop performs a
972-
// single CALLDATALOAD per iteration instead of re-reading `ids.length` and `ids[i]` twice each.
973+
bool isGuardian = OFFERS_REGISTRY.canRevokeOffer(msg.sender);
973974
uint256 length = ids.length;
974975
for (uint256 i; i < length; ++i) {
975976
uint8 id = ids[i];
976-
if (!o.isLive(id)) revert LibBorrowErrors.OfferNotFound();
977-
if (o.slab[id].proposer != msg.sender && !checkedCanRevokeAny) {
978-
OFFERS_REGISTRY.checkCanRevokeOffer(msg.sender);
979-
checkedCanRevokeAny = true;
980-
}
981-
o.removeOffer(id);
977+
o.removeOffer(id, msg.sender, isGuardian);
982978
emit OfferRevoked(id, msg.sender);
983979
}
984980
}

‎src/interfaces/borrow/IBorrowOffers.sol‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,12 @@ interface IBorrowOffers {
9696
function proposeOffer(uint128 collateral, uint128 debtShares, uint40 expiresAt) external returns (uint8 id);
9797

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

‎src/interfaces/borrow/IBorrowOffersRegistry.sol‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ interface IBorrowOffersRegistry {
4949
/// a role, so an ownership handover never leaves the new owner without powers.
5050
function checkCanCreateOffer(address account) external view;
5151

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

5860
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
5961
/* CONFIGURATION */

‎src/libs/borrow/LibBorrowErrors.sol‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ library LibBorrowErrors {
7878
/// @notice Thrown when the callback is called by an address other than Morpho.
7979
error NotMorpho();
8080

81+
/// @notice Thrown when the caller of {IBorrowOffers.revokeOffers} is neither the offer's
82+
/// recorded proposer nor a registry guardian/owner. Deliberately the same selector as
83+
/// Solady's `Unauthorized()`, which the registry's own authorization checks revert
84+
/// with, so callers see one selector for every authorization failure.
85+
error Unauthorized();
86+
8187
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
8288
/* OFFERS */
8389
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/

‎src/libs/borrow/LibBorrowOffers.sol‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@ library LibBorrowOffers {
143143
delete s.slab[id];
144144
}
145145

146+
/// @notice Authorized removal for {IBorrowOffers.revokeOffers}: `isGuardian` callers (the
147+
/// registry's guardian/owner revoke power, resolved once per batch by the caller) may
148+
/// remove any offer; any other caller must be the offer's recorded proposer.
149+
/// @dev Liveness resolves first, so an unknown id reverts {LibBorrowErrors.OfferNotFound} for
150+
/// every caller; an unauthorized caller then reverts {LibBorrowErrors.Unauthorized}.
151+
function removeOffer(BorrowOffersStorage storage s, uint8 id, address caller, bool isGuardian) internal {
152+
if (!isLive(s, id)) revert LibBorrowErrors.OfferNotFound();
153+
if (!isGuardian && s.slab[id].proposer != caller) revert LibBorrowErrors.Unauthorized();
154+
removeOffer(s, id);
155+
}
156+
146157
/// @dev Allocates the lowest free slab index and marks it live, or reverts
147158
/// {LibBorrowErrors.TooManyOffers} when all `MAX_OFFERS` slots hold unexpired offers.
148159
/// Expired offers are pruned first (bit cleared, slot zeroed; housekeeping only, mirroring
@@ -162,6 +173,8 @@ library LibBorrowOffers {
162173
uint256 freeBits = ~liveBits & ((uint256(1) << MAX_OFFERS) - 1);
163174
if (freeBits == 0) revert LibBorrowErrors.TooManyOffers();
164175
id = uint8(LibBit.ffs(freeBits));
176+
// Only bits below `MAX_OFFERS` (32) survive the mask above, so the cast cannot truncate.
177+
// forge-lint: disable-next-line(unsafe-typecast)
165178
s.liveBits = uint32(liveBits | (uint256(1) << id));
166179
}
167180

@@ -224,6 +237,8 @@ library LibBorrowOffers {
224237
}
225238
emit IBorrowOffers.OfferConsumed(walkOffer.id, walkOffer.filledCollateral, walkOffer.filledShares, exhausted);
226239
}
240+
// Clearing bits of an already-`uint32` value cannot truncate.
241+
// forge-lint: disable-next-line(unsafe-typecast)
227242
if (clearBits != 0) s.liveBits = uint32(s.liveBits & ~clearBits);
228243
}
229244

@@ -263,6 +278,8 @@ library LibBorrowOffers {
263278
}
264279
if (block.timestamp < offer.activeAt) continue;
265280
WalkOffer memory walkOffer = offers[walkLength];
281+
// `id` indexes a set bit of the 32-bit live bitmap, so the cast cannot truncate.
282+
// forge-lint: disable-next-line(unsafe-typecast)
266283
walkOffer.id = uint8(id);
267284
walkOffer.remainingCollateral = offer.remainingCollateral;
268285
walkOffer.remainingDebtShares = offer.remainingDebtShares;
@@ -331,10 +348,12 @@ library LibBorrowOffers {
331348

332349
// CONSUME. `fillCollateral <= remainingCollateral` and `fillShares <= remainingDebtShares`
333350
// by construction, so the uint128 casts and subtractions cannot overflow/underflow.
351+
// forge-lint: disable-start(unsafe-typecast)
334352
walkOffer.remainingCollateral -= uint128(fillCollateral);
335353
walkOffer.remainingDebtShares -= uint128(fillShares);
336354
walkOffer.filledCollateral = uint128(fillCollateral);
337355
walkOffer.filledShares = uint128(fillShares);
356+
// forge-lint: disable-end(unsafe-typecast)
338357
totalSeized += fillCollateral;
339358
totalDebtShares += fillShares;
340359

‎test/borrow/BorrowOffersRegistry.t.sol‎

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,37 +162,34 @@ contract BorrowOffersRegistryTest is Test {
162162
registry.checkCanCreateOffer(proposer);
163163
}
164164

165-
function test_CheckCanRevokeOffer_GuardianRoundTrip() public {
166-
// Not a guardian yet: rejected
167-
vm.expectRevert(Unauthorized.selector);
168-
registry.checkCanRevokeOffer(guardian);
165+
function test_CanRevokeOffer_GuardianRoundTrip() public {
166+
// Not a guardian yet: no revoke power
167+
assertFalse(registry.canRevokeOffer(guardian), "non-guardian should have no revoke power");
169168

170-
// Enabled: passes (no revert)
169+
// Enabled: revoke power granted
171170
vm.prank(owner);
172171
registry.setGuardian(guardian, true);
173-
registry.checkCanRevokeOffer(guardian);
172+
assertTrue(registry.canRevokeOffer(guardian), "guardian should have revoke power");
174173
assertTrue(registry.hasAnyRole(guardian, registry.GUARDIAN_ROLE()), "guardian role bit should be set");
175174

176-
// Disabled again: rejected
175+
// Disabled again: revoke power revoked
177176
vm.prank(owner);
178177
registry.setGuardian(guardian, false);
179-
vm.expectRevert(Unauthorized.selector);
180-
registry.checkCanRevokeOffer(guardian);
178+
assertFalse(registry.canRevokeOffer(guardian), "removed guardian should lose revoke power");
181179
}
182180

183181
function test_Checks_OwnerPassesWithoutRoles() public view {
184182
// The owner is authorized by derivation, without holding any role bit
185183
assertEq(registry.rolesOf(owner), 0, "owner should hold no explicit role");
186184
registry.checkCanCreateOffer(owner);
187-
registry.checkCanRevokeOffer(owner);
185+
assertTrue(registry.canRevokeOffer(owner), "owner should have revoke power by derivation");
188186
}
189187

190188
function test_Checks_StrangerRejected() public {
191189
vm.expectRevert(Unauthorized.selector);
192190
registry.checkCanCreateOffer(stranger);
193191

194-
vm.expectRevert(Unauthorized.selector);
195-
registry.checkCanRevokeOffer(stranger);
192+
assertFalse(registry.canRevokeOffer(stranger), "stranger should have no revoke power");
196193
}
197194

198195
function test_Checks_RoleSeparation() public {
@@ -201,9 +198,8 @@ contract BorrowOffersRegistryTest is Test {
201198
registry.setGuardian(guardian, true);
202199
vm.stopPrank();
203200

204-
// A proposer cannot revoke offers
205-
vm.expectRevert(Unauthorized.selector);
206-
registry.checkCanRevokeOffer(proposer);
201+
// A proposer holds no batch-wide revoke power (it may still revoke its own offers)
202+
assertFalse(registry.canRevokeOffer(proposer), "proposer should have no revoke power");
207203

208204
// A guardian cannot create offers
209205
vm.expectRevert(Unauthorized.selector);
@@ -525,13 +521,12 @@ contract BorrowOffersRegistryTest is Test {
525521

526522
// The new owner is authorized by derivation
527523
registry.checkCanCreateOffer(newOwner);
528-
registry.checkCanRevokeOffer(newOwner);
524+
assertTrue(registry.canRevokeOffer(newOwner), "new owner should have revoke power");
529525

530526
// The old owner has no lingering powers
531527
vm.expectRevert(Unauthorized.selector);
532528
registry.checkCanCreateOffer(owner);
533-
vm.expectRevert(Unauthorized.selector);
534-
registry.checkCanRevokeOffer(owner);
529+
assertFalse(registry.canRevokeOffer(owner), "old owner should lose revoke power");
535530

536531
vm.prank(owner);
537532
vm.expectRevert(Unauthorized.selector);

‎test/borrow/MorphoBorrowPositionOffers.t.sol‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,57 @@ contract MorphoBorrowPositionOffersTest is Test {
12551255
assertEq(pos.offerCount(), 0, "guardian revoked both");
12561256
}
12571257

1258+
/// @notice An empty batch is a no-op for every caller class: no revert, no state change and no
1259+
/// events (the registry revoke-power read is a state-neutral view).
1260+
function test_revokeOffers_emptyBatchIsNoOp() public {
1261+
_enterBand(0.7e18);
1262+
_proposeAtPrice(_positionShares() / 4, 1.2e18);
1263+
1264+
uint8[] memory none = new uint8[](0);
1265+
vm.recordLogs();
1266+
vm.prank(makeAddr("rando"));
1267+
pos.revokeOffers(none);
1268+
vm.prank(guardian);
1269+
pos.revokeOffers(none);
1270+
assertEq(pos.offerCount(), 1, "book untouched by empty batches");
1271+
assertEq(vm.getRecordedLogs().length, 0, "no events emitted");
1272+
}
1273+
1274+
/// @notice The guardian power is not limited to the timelock window: a standing (active,
1275+
/// consumable) offer can still be pulled at any later stage.
1276+
function test_revokeOffers_guardianRevokesActiveOffer() public {
1277+
_enterBand(0.7e18);
1278+
uint8 id = _proposeAtPrice(_positionShares() / 4, 1.2e18);
1279+
_warpActive();
1280+
assertTrue(pos.isConsumable(id), "offer is live and consumable");
1281+
1282+
uint8[] memory ids = new uint8[](1);
1283+
ids[0] = id;
1284+
vm.prank(guardian);
1285+
pos.revokeOffers(ids);
1286+
assertEq(pos.offerCount(), 0, "guardian revoked the active offer");
1287+
}
1288+
1289+
/// @notice A multi-offer batch emits one {OfferRevoked} per removed offer, each attributed to
1290+
/// the batch caller.
1291+
function test_revokeOffers_batchEmitsPerOffer() public {
1292+
_enterBand(0.7e18);
1293+
uint256 shares = _positionShares() / 8;
1294+
uint8 a = _proposeAtPrice(shares, 1.2e18);
1295+
uint8 b = _proposeAtPrice(shares, 1.3e18);
1296+
1297+
uint8[] memory ids = new uint8[](2);
1298+
ids[0] = a;
1299+
ids[1] = b;
1300+
vm.expectEmit(true, true, false, false, address(pos));
1301+
emit IBorrowOffers.OfferRevoked(a, guardian);
1302+
vm.expectEmit(true, true, false, false, address(pos));
1303+
emit IBorrowOffers.OfferRevoked(b, guardian);
1304+
vm.prank(guardian);
1305+
pos.revokeOffers(ids);
1306+
assertEq(pos.offerCount(), 0, "both revoked");
1307+
}
1308+
12581309
function test_slab_recyclesFreedSlots() public {
12591310
_enterBand(0.7e18);
12601311
uint256 shares = _positionShares() / 8;

0 commit comments

Comments
 (0)