3F: Grunt
Cantina Security Report
Organization
- @3f-company
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
17 findings
13 fixed
4 acknowledged
Informational
25 findings
21 fixed
4 acknowledged
Medium Risk1 finding
Liquidation can produce a positive performance-fee basis while PositionManager NAV falls
Context: MorphoBorrowPosition.sol#L255-L306, PositionManagerBase.sol#L65-L223
A liquidation changes the borrow position directly.
MorphoBorrowPosition.preLiquidate(MorphoBorrowPosition.sol:344) and native Morpho liquidation both settle on the Morpho market without touching PositionManager state, so the performance reference (lastTotalAssets,lastDebt) is never rebased in that call.preLiquidate's proportional path is permissionless; the offer/band path additionally needs a standing offer authorized by aPROPOSERonBorrowOffersRegistry.The next accrual computes the basis from the stale reference at
PositionManagerBase.sol:188-197. A liquidation cuts collateral and debt in a ratio that leaves the surviving position below the reference LTV, soscaledLastDebt > currentDebtholds and the basis reads positive even though NAV dropped.// src/manager/base/PositionManagerBase.sol:188-197uint256 lastCollat = _storage.lastTotalAssets + _lastDebt;// basis = mulDiv(lastDebt, currentCollat, lastCollat) - currentDebt// = LTV_ref * currentCollat - currentDebt// Round up on the minuend (mulDivUp) so the basis is biased larger — favors the// protocol, consistent with conservative-to-protocol rounding elsewhere.uint256 scaledLastDebt = _lastDebt.mulDivUp(currentCollat, lastCollat);if (scaledLastDebt > currentDebt) { basis = scaledLastDebt - currentDebt; advanceReference = true;}The performance fee minted on that basis is on top of the ordinary liquidation loss. The minted amount depends on the fee configuration and the management-fee deduction.
Example
USDC debt asset and wUSCC collateral, both 6 decimals, so collateral and debt are directly comparable. Assume a 10% (1000 BPS) performance fee.
- Reference: 1,250,000 wUSCC collateral (
1.25e12), 1,000,000 USDC debt (1e12). Reference LTV 80%, NAV 250,000 USDC (2.5e11). - A liquidation repays 500,000 USDC and seizes 525,000 wUSCC. Post-state: 725,000 wUSCC (
7.25e11), 500,000 USDC debt (5e11). - NAV falls to 225,000 USDC, a drop of 25,000 USDC. The stale reference gives
scaledLastDebt = 1e12 * 7.25e11 / 1.25e12 = 5.8e11(580,000 USDC), sobasis = 5.8e11 - 5e11 = 8e10, i.e. 80,000 USDC. - The next accrual mints performance-fee shares worth 8,000 USDC on that basis, and
advanceReferencere-anchors the reference at the post-liquidation state. NAV fell by 25,000 USDC while the fee recipient was paid 8,000 USDC.
Recommendation
Reconcile the performance reference with the borrow-position state after a liquidation before computing the basis, so a NAV decrease cannot yield a positive basis. If the current behavior is intended, document external debt relief (direct Morpho repayment and liquidation recovery with no NAV improvement) as fee-bearing.
- Reference: 1,250,000 wUSCC collateral (
Low Risk17 findings
Centrifuge forceEnd lets resolve() clear a Retargetter operation while the venue request is still pending
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Context: LibStorage.sol#L138-L147, LibStorage.sol#L264-L275, Retargetter.sol#L298-L313, Retargetter.sol#L354-L379, CentrifugeFund.sol#L358-L399, CentrifugeFund.sol#L445-L513
CentrifugeFund.forceEndsetsinternalState = ENDEDafter checking only that there is no claimable fill (maxMint/maxWithdraw), and it checks cancellation claimables only when the order is alreadyRECOVERING(CentrifugeFund.sol:379-402). The still-openpendingDepositRequestat the vault is never checked, so an order with an outstanding venue request readsENDED.LibStorage.checkNoPendingOrderacceptsENDEDorEMPTYand clears the order (LibStorage.sol:144-149), soRetargetter.resolvepasses its no-pending-order gate and callsclearOperation(Retargetter.sol:344-353). The operation's link to the outstanding venue request is gone.unlock()later mints the fill and paysorder.receiver(CentrifugeFund.sol:257-265), which by then belongs to whichever operation is reusing the Fund.Centrifugeaggregates by(vault, controller)and exposes request ID 0 (_PENDING_REQUEST), so the request ID cannot identify which operation the liability belongs to.// src/funds/centrifuge/CentrifugeFund.sol:376-404 address _vault = $.vault; // Revert if claimable fills exist (must be drained via unlock() first). if (order.mode == Mode.DEPOSIT) { if (ICentrifugeVault(_vault).maxMint(address(this)) > 0) { revert LibFundsErrors.PendingClaimableAssets(); } } else { if (ICentrifugeVault(_vault).maxWithdraw(address(this)) > 0) { revert LibFundsErrors.PendingClaimableAssets(); } } // Revert if recoverable cancel assets exist (must be drained via recover() first) if (_internalState == State.RECOVERING) { if (order.mode == Mode.DEPOSIT) { if (ICentrifugeVault(_vault).claimableCancelDepositRequest(_PENDING_REQUEST, address(this)) > 0) { revert LibFundsErrors.PendingClaimableAssets(); } } else { if (ICentrifugeVault(_vault).claimableCancelRedeemRequest(_PENDING_REQUEST, address(this)) > 0) { revert LibFundsErrors.PendingClaimableAssets(); } } } $.internalState = State.ENDED; emit OrderForceEnded(_orderId, msg.sender); }// src/libs/manager/rebalancer/LibStorage.sol:144-149 function checkNoPendingOrder(RetargetterOperation storage self, address fund) internal { if (!self.orderLive) return; State state_ = IFund(fund).state(order(self)); if (state_ != State.ENDED && state_ != State.EMPTY) revert LibRetargetterErrors.OrderPending(); clearOrder(self); }Centrifuge share/asset conversions and partial unlocks can leave a bounded raw-atom remainder, which stays with the order/controller whose claim was converted. That does not repair cross-order attribution.
Example
Assets are USDC (debt, 6 decimals) and wUSCC (collateral, 6 decimals), quoted 1:1 for the arithmetic.
- Operation 1 sends 5,000 USDC (
5e9) into a pending Centrifuge deposit request. - A Fund
OPERATORcallsforceEnd(); no fill is claimable yet, so the check passes and the order readsENDED. - Repayment (or the 90-day
REPAYMENT_DEADLINE_OFFSETexpiry sync) letsresolve()run and clear Retargetter state. - Operation 2 starts and reuses the same Fund.
- Centrifuge fulfills late. Operation 2 calls
unlock()and receives Operation 1's wrapped-share output.
The unmatched 5,000 wUSCC (
5e9) lands as collateral with no shares minted, so PositionManager NAV goes from 6,000 to 11,000 USDC against an unchanged supply of 6,000e18 shares.burn()payscollateral = totalCollateral * shares / (totalSupply + virtualShareOffset)withvirtualShareOffset = 1e12(PositionManager.sol:74-75), so burning all 6,000e18 shares against 11,000 wUSCC (1.1e10) returns1.1e10 * 6e21 / (6e21 + 1e12) = 10,999,999,998base units, i.e. 10,999.999998 wUSCC, leaving 2 base units behind. That is 4,999.999998 wUSCC over the holder's own 6,000, and Operation 1's controller is short the full 5,000 USDC. The path needs Request terminality plus exit authority; repayment leaves lenders whole, and a direct loss requires default or under-repayment.Recommendation
Preserve each controller's pending-claim ownership across
forceEnd()andclearOperation()until late venue claims settle. Failing that, document in the operator runbook that old-controller ownership is preserved and Fund reuse is blocked until the venue request and all claimables are terminal.Request.mint() honors a stored mint authorization after the PositionManager principal cap falls
Context: Retargetter.sol#L230-L248, LibStorage.sol#L149-L175, Request.sol#L328-L339
Retargetter.authorizeMinting()checks the principal cap once, at authorization time:Retargetter.sol:277passesmaxPrincipal(operation_.positionManager)intoLibStorage.checkOffer(), which reverts withPrincipalCapExceededwhen PT supply plus outstanding authorizations plus the new amount exceeds it (LibStorage.sol:174).maxPrincipal()is derived from live PositionManager collateral and debt (Retargetter.sol:688-695), so a public liquidation against the PositionManager reduces it afterwards.Request.mint()reads the stored authorization and checks only the request state and the caller's slippage bounds. It transfers the authorized principal and mints PT/YT without reading the current cap, so the broker can mint the old amount after the cap falls. A later Retargetter settlement can require the PositionManager to pay the minimum tick on that excess capital.// src/request/Request.sol:378-389 function mint(uint128 maxPt, uint128 minYt) external nonReentrant { if (_syncWithdrawalStatus()) revert LibRequestErrors.AlreadyRepaid(); (uint128 ptMintAuth, uint128 ytMintAuth) = msg.sender.mintAuth(); // Early return when no authorization — prevents griefing where a zero-authorized caller // repeatedly calls mint to bump lastMintTimestamp and permanently delay setRepaid(). if (ptMintAuth == 0 && ytMintAuth == 0) return; if (ptMintAuth > maxPt || ytMintAuth < minYt) revert LibRequestErrors.SlippageExceeded(); msg.sender.updateMintAuth(0, 0); _asset().safeTransferFrom(msg.sender, address(this), ptMintAuth); _mint(msg.sender, ptMintAuth, ytMintAuth); _requestStorage().lastMintTimestamp = uint40(block.timestamp); }Sequence:
- Authorize broker principal
PwhileP <= maxPrincipal. - Execute an independent public liquidation that reduces
maxPrincipalbelowP. - Call
Request.mint()with the stored authorization. - The broker deposits
Pand receives PT/YT under the original commitment. On settlement it recovers its principal and earns the promised tick.
The client treats the stored authorization as a firm capital commitment, so honoring the grant after PositionManager state changes is the intended policy; an exercise-time cap check would be a different policy. The broker and liquidator act independently, and the liquidation bonus belongs to the standalone liquidation path. The durable authorization adds the committed Request liability and its tick. A loss occurs only if the PositionManager cannot back the commitment at settlement.
The amounts are integers:
Request.consume()floors proportional YT once for the consumed nonce (Request.sol:422,mulDiv), while settlement rounds accrued YT up (RetargetterQuoter.sol:205,ytSupply.fullMulDivUp(duration, horizon)). The ceiling is a conservative debt-asset obligation for the firm commitment.Recommendation
Define the authorization's backing policy explicitly and apply it consistently when
Request.mint()exercises the stored commitment. If unreserved firm commitments are intentional, document the policy and its backing requirements.- Authorize broker principal
A dust basis clears the whole held management-fee deduction
Context: PositionManagerBase.sol#L65-223. A permissionless caller can create the dust basis through Morpho, but the caller receives no native payout. A later PositionManager flow and an aligned fee recipient are required for fee dilution.
While the performance reference is held,
heldManagementFeeAssetsshould offset the next positive performance basis._pendingFees()computes the basis asscaledLastDebt - currentDebtand setsadvanceReference = trueon any positive value (PositionManagerBase.sol:194-197), then zeroesheldManagementFees_on the advance path (PositionManagerBase.sol:229).The performance-fee branch still requires
basis > managementFeeAssets + heldManagementFees_(PositionManagerBase.sol:223), so a basis too small to mint performance-fee shares clears the full accumulated deduction.// src/manager/base/PositionManagerBase.sol:193-197 uint256 scaledLastDebt = _lastDebt.mulDivUp(currentCollat, lastCollat); if (scaledLastDebt > currentDebt) { basis = scaledLastDebt - currentDebt; advanceReference = true; }// src/manager/base/PositionManagerBase.sol:223-229 if (fd.performanceFee > 0 && basis > managementFeeAssets + heldManagementFees_) { totalFeeAssets += (basis - managementFeeAssets - heldManagementFees_).mulDiv(fd.performanceFee, BPS); } // While the reference is held, the current interval's management fee joins the accumulator // so the next crystallization deducts it; on advance the pending deduction is consumed (or, // for any excess above the basis, forgiven) and the accumulator restarts. heldManagementFees_ = advanceReference ? 0 : heldManagementFees_ + managementFeeAssets;Example
Debt asset USDC (6 decimals), collateral wUSCC (6 decimals), 2% management fee, 20% performance fee, and a hold window long enough for the accumulator to reach 50,000 USDC.
heldManagementFeeAssetsreaches 50,000 USDC (5e10) while a zero-interest Morpho position keeps the basis at zero.- An external caller repays 1 base unit, 0.000001 USDC, through
Morpho.repay(..., onBehalf). Collateral is unchanged, so the next basis is 1. basis > managementFeeAssets + heldManagementFees_is1 > 5e10 + current interval charge, false, so no performance-fee shares mint.advanceReferenceis still true, so line 229 sets the accumulator to 0 and the 50,000 USDC deduction is written off.- A later gain with a basis of 500,000 USDC (5e11) is then charged on the full amount instead of 450,000, so the fee recipient mints 100,000 USDC of fee shares instead of 90,000. The extra 10,000 USDC (50,000 * 20%) dilutes LPs. The unrelated caller receives no direct payout.
Scope note: this is the one-atom positive-basis transition where a floor mints zero fee shares while still advancing the reference and clearing the held deduction, distinct from zero-share management-fee conversion and zero-NAV scaling.
Recommendation
Prevent a positive basis smaller than the held deduction from advancing the reference and clearing
heldManagementFeeAssets. If forgiving the deduction on any advance is intentional, keep the rule and document that permissionless repayment can trigger it.Empty-good-debt exit skips held-fee scaling and leaves an oversized deduction
Context: LibStorage.sol#L166-L207 contains the affected code. The configured fee recipient misses fee shares. Remaining or later LP cohorts receive the excess fee shield.
heldManagementFeeAssetsholds the management fees charged since the performance reference last advanced, andPositionManagerBase.sol:223-224deducts it from the next positive performance basis.LibStorage.rebaseSnapshot()returns early atLibStorage.sol:270whenrefDebt > 0 && newCollat == 0, preserving the reference during a bad-debt episode. That return also skips thenewSupply / prevSupplyscaling ofheldManagementFeeAssetsatLibStorage.sol:285-290. The deduction belongs to the shares that incurred it, so the early return leaves the exited LPs' slice attached to the remaining shares.// src/libs/manager/LibStorage.sol:270 if (refDebt > 0 && newCollat == 0) return;// src/libs/manager/LibStorage.sol:285-290 if (newSupply != prevSupply && prevCollat > 0) { uint256 heldManagementFeeAssets = self.heldManagementFeeAssets; if (heldManagementFeeAssets > 0) { self.heldManagementFeeAssets = heldManagementFeeAssets.mulDiv(newSupply, prevSupply); } }Example
Debt asset USDC and collateral wUSCC are both 6 decimals, so
virtualShareOffsetis10 ** (18 - 6) = 1e12and shares are 18 decimals.- LPs deposited 1,000,000 USDC (1e12) against a 1e24 share supply, and
heldManagementFeeAssetshas reached 10,000 USDC (1e10), roughly six months at the 200 BPS management fee cap. - An exit burns 2e23 shares (20% of supply) and drains the last healthy position, so
newCollat == 0while underwater positions and 8e23 shares remain. - The early return leaves the accumulator at 10,000 USDC (1e10) instead of scaling it to 8,000 USDC (8e9), which is
1e10 * 8e23 / 1e24, for the remaining shares. - A later positive basis consumes the stale deduction, so 2,000 USDC of basis is shielded that should have been charged. At a 2000 BPS performance fee the fee recipient loses 400 USDC (4e8); at the 5000 BPS
MAX_PERFORMANCE_FEEit is 1,000 USDC (1e9).
A partial exit is enough, zero supply is not required. A zero-supply exit has the largest effect because newly issued shares can inherit the exited LPs' full deduction. User principal is unaffected, and the client may accept fee under-collection as policy.
Recommendation
Scale
heldManagementFeeAssetsby the remaining share ratio before the empty-good-debt early return, so exited shares cannot leave their deduction with later shares. Otherwise document and accept that remaining or later LPs inherit the exited LPs' deduction and pay reduced future performance fees.- LPs deposited 1,000,000 USDC (1e12) against a 1e24 share supply, and
Rebalance re-anchors the performance reference at the post-loss NAV, so recovery is charged as performance
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Context: PositionManagerRebalancing.sol#L45-L119, PositionManagerLP.sol#L190-L236, LibStorage.sol#L166-L207. The configured fee recipient receives the direct fee.
rebalance()accrues fees and captures NAV atPositionManagerRebalancing.sol:83, runs the operations, then calls_rebaseReference()atPositionManagerRebalancing.sol:113with the post-operation state. ThemaxRebalanceLosscheck atPositionManagerRebalancing.sol:116-123runs after that rebase and only bounds the loss, it does not record it.LibStorage.rebaseSnapshot()derivesprevCarryfrom the pre-call aggregates (LibStorage.sol:274-275) and writes the new reference asnewDebt - carryagainst the post-call collateral (LibStorage.sol:292-295). A rebalance is supply-neutral, socarry == prevCarryand the NAV lost during the call never enters it. The reference lands at the post-loss trough, and recovery back to the pre-rebalance NAV reads as a gain.// src/manager/base/PositionManagerRebalancing.sol:110-123 // Rebase the performance reference to the post-rebalance state. A rebalance is a flow, not // a gain: the share supply is unchanged, so any carried pending basis is preserved as-is and // the rebalance neither crystallizes a performance fee nor writes off accrued debt carry. uint256 totalAssetsAfter = _rebaseReference(totalAssetsBefore, debtBefore, ERC20.totalSupply()); // Check that totalAssets didn't decrease by more than maxRebalanceLoss if (totalAssetsAfter < totalAssetsBefore) { uint256 loss = totalAssetsBefore - totalAssetsAfter; // loss * BPS / totalAssetsBefore > maxRebalanceLoss // Rearranged to avoid division: loss * BPS > maxRebalanceLoss * totalAssetsBefore if (loss * BPS > uint256(_storage.rebalanceConfig.maxRebalanceLoss) * totalAssetsBefore) { revert LibManagerErrors.RebalanceLossExceedsMax(); } }// src/libs/manager/LibStorage.sol:271-295 uint256 carry; if (refDebt > 0 && prevSupply > 0 && newSupply > 0) { uint256 refCollat = self.lastTotalAssets + refDebt; uint256 scaledRefDebt = refDebt.mulDivUp(prevCollat, refCollat); uint256 prevCarry = FixedPointMathLib.zeroFloorSub(prevDebt, scaledRefDebt); // Preserve the per-share carry across the supply change. carry = prevCarry.mulDiv(newSupply, prevSupply); if (carry > newDebt) carry = newDebt; // Preserve the per-share pending management fee deduction the same way. Skipped when the // pre-flow good-debt universe is empty (a rescue flow out of a full bad-debt episode): // shares are then minted against a zero asset base, so the supply ratio is unmoored from // any price and scaling would inflate the deduction far beyond the fees ever charged. The // accumulator stays nominal instead, matching its definition (fees charged since the last // advance). if (newSupply != prevSupply && prevCollat > 0) { uint256 heldManagementFeeAssets = self.heldManagementFeeAssets; if (heldManagementFeeAssets > 0) { self.heldManagementFeeAssets = heldManagementFeeAssets.mulDiv(newSupply, prevSupply); } } } uint256 newRefDebt = newDebt - carry; self.lastDebt = newRefDebt; // Good-debt aggregation guarantees newCollat >= newDebt >= newRefDebt. self.lastTotalAssets = newCollat - newRefDebt;LPs pay performance fees on recovery of value they already held. The loss can come from bridge fees, venue slippage, or rebalance rounding, so an ordinary rebalance triggers it without owner action. Scope covers the ordinary path; no separate split-flow path is asserted.
Example
Assumes a 2000 BPS performance fee, no management fee, and
maxRebalanceLoss = 100BPS.- PositionManager holds 10,000,000 USDC of quoted wUSCC collateral (1e13) against 5,000,000 USDC debt (5e12), NAV 5,000,000 USDC. Reference is
lastDebt = 5e12,lastTotalAssets = 5e12. - A rebalance loses 25,000 USDC of collateral, 50 BPS of NAV, inside the cap.
prevCarryis 0, sorebaseSnapshot()re-anchors atlastDebt = 5e12,lastTotalAssets = 9,975,000 - 5,000,000 = 4,975,000 USDC (4.975e12). - Collateral recovers to 10,000,000 USDC and NAV returns to 5,000,000 USDC, no new high.
- The next accrual reads
basis = mulDivUp(5e12, 1e13, 9.975e12) - 5e12 = 12,531.328321 USDCand mints the recipient fee shares worth 2,506.265664 USDC. The charge is the levered slice of the recovery, not the full 25,000 USDC.
Recommendation
Preserve the pre-rebalance performance reference across an allowed NAV loss, so recovery to that reference mints no performance-fee shares. If charging recovery as performance is the intended policy, document it.
Offer fill rounding can push LTV past liquidationLtv and unlock near-total equity liquidation
Context: LibBorrowOffers.sol#L287-L408, MorphoBorrowPosition.sol#L319-L356, MorphoBorrowPosition.sol#L438-L458
The offer band is permissionless to consume while
safeLtv < LTV <= liquidationLtv(MorphoBorrowPosition.sol:360-369)._consumeOfferssnapshots Morpho's pre-repayment totals (MorphoBorrowPosition.sol:446-459), and_priceActionevaluatesstrictlyLowersLtvagainst that snapshot with collateral values rounded down and debt values rounded up. On a virtual-share boundary the fill credits a debt-asset unit that disappears once Morpho updatestotalBorrowAssets/totalBorrowShares: remaining debt rounds to the same value while collateral drops, so post-repayment LTV is flat or higher even though the pre-state check accepted the fill. Independently floored collateral and ceil'd share terms give the same outcome by a different route, off by one raw loan atom.Once LTV crosses
liquidationLtvbut stays under marketLLTV,preLiquidatedispatches to the proportional path, which can repay the remaining shares and seize the remaining collateral. The liquidator reaches that in one transaction via theonPreLiquidatecallback, or with a second call after the first fill.liquidationLtv == LLTVblocks the escalation (the reviewed fleet snapshot used that config), so no current deployment exposure was established.// src/libs/borrow/LibBorrowOffers.sol:436-446 uint256 seizedValue = fillCollateral.mulDiv(inp.price, ORACLE_PRICE_SCALE); uint256 repaidDebtValue = fillShares.toAssetsUp(inp.totalBorrowAssets, inp.totalBorrowShares); if (!isProfitableAboveBonusFloor(seizedValue, repaidDebtValue, inp.minOfferBonusBps)) return FillAction.Skip; uint256 remainingDebtValue = remainingPositionShares.toAssetsUp(inp.totalBorrowAssets, inp.totalBorrowShares); uint256 remainingCollateralValue = remainingPositionCollateral.mulDiv(inp.price, ORACLE_PRICE_SCALE); if (!strictlyLowersLtv(seizedValue, repaidDebtValue, remainingDebtValue, remainingCollateralValue)) { return FillAction.Stop; } return FillAction.Consume;Example
wUSCC/USDC market at 1:1 oracle price,
safeLtv = 45%,liquidationLtv = 50%,LLTV = 62.5%.- Position holds 9,973,308.160223 wUSCC collateral against 4,986,654.080111 USDC debt (4,326,631,081,525,540,850 borrow shares). Equity is 4,986,654.080112 USDC and LTV sits inside the band.
- Proposer posts an offer of 2 collateral atoms for 1 debt share.
- Liquidator calls
preLiquidate(position, 2, 0, data). The fill seizes 0.000002 wUSCC and repays 1 share; rounded position debt is 4,986,654.080111 USDC before and after Morpho's update, so LTV rises past 50%. - Inside
onPreLiquidate, the liquidator re-enterspreLiquidatein shares mode for all remaining shares. The proportional path repays the rest and seizes the remaining 9,973,308.160221 wUSCC. - Liquidator spends 4,986,654.080112 USDC and receives 9,973,308.160223 wUSCC: net 4,986,654.080111 USDC of the 4,986,654.080112 USDC starting equity, one raw atom short of the whole position.
The collateral-floor variant reaches the same cascade while making debt progress and captures the full 150.000000 USDC quoted equity.
Recommendation
After Morpho updates the debt totals, re-check the position's final balances and require LTV to be below both entry LTV and
liquidationLtv.Partitioning a liquidation target skips the best offer and fills from a worse one
Context: MorphoBorrowPosition.sol#L255-L329, LibBorrowOffers.sol#L287-L408
MorphoBorrowPosition.preLiquidatelets a permissionless liquidator pick the collateral or debt-share target. In the offer band (safeLtv < LTV <= liquidationLtv)_walkvisits offers cheapest-first for the owner, but a below-floor offer is skipped, not stopped (LibBorrowOffers.sol:319)._computeFillroundsfillSharesup. An offer whose whole fill sits exactly on the configured bonus floor can miss that floor by one raw share once the fill is partial, so_priceActionreturnsSkipfor the best offer and the walk fills from a worse one. Splitting one target into two calls therefore buys the same collateral for less repaid USDC.// src/libs/borrow/LibBorrowOffers.sol:389-391 if (fillCollateral == 0) return (FillAction.Skip, 0, 0); uint256 fillShares = fillCollateral.mulDivUp(remainingDebtShares, remainingCollateral);// src/libs/borrow/LibBorrowOffers.sol:436-438 uint256 seizedValue = fillCollateral.mulDiv(inp.price, ORACLE_PRICE_SCALE); uint256 repaidDebtValue = fillShares.toAssetsUp(inp.totalBorrowAssets, inp.totalBorrowShares); if (!isProfitableAboveBonusFloor(seizedValue, repaidDebtValue, inp.minOfferBonusBps)) return FillAction.Skip;The invariant that must hold:
For identical seized collateral, partitioning one liquidation into multiple calls must not materially reduce the debt repaid solely because of offer-walk rounding.
Example
Raw units, wUSCC and USDC both 6 decimals, oracle price 1:1 (
price = ORACLE_PRICE_SCALE),minOfferBonusBps = 100, andtotalBorrowAssets == totalBorrowSharesso one share converts to one USDC atom.Position: 2,000,000 wUSCC collateral, 1,700,000 USDC debt (LTV 85%, inside the band). Offer A (best for owner): 1,010,000 wUSCC for 1,000,000 shares. Offer B (worse): 1,100,000 wUSCC for 1,000,000 shares.
- Whole target
seizedAssets = 1_010_000_000_000. Offer A fills whole:fillShares = 1_000_000_000_000, excess10_000_000_000 >= ceil(1e12 * 100 / 10000) = 10_000_000_000, floor met. Liquidator repays 1,000,000 USDC. - Split call 1,
seizedAssets = 505_000_000_001. On A,fillShares = ceil(505_000_000_001 * 100 / 101) = 500_000_000_001, excess5_000_000_000 < ceil(500_000_000_001 / 100) = 5_000_000_001. A is skipped; B fills at 1.1, repaying459_090_909_092. - Split call 2,
seizedAssets = 504_999_999_999. On A,fillShares = 500_000_000_000, excess4_999_999_999 < 5_000_000_000. A is skipped again; B repays459_090_909_090. - Same 1,010,000 wUSCC seized, 918,181.818182 USDC repaid instead of 1,000,000. The owner loses about 81,818 USDC of debt reduction, and offer A stays unconsumed.
Both fills pass
strictlyLowersLtv(fill ratio 1.1 is below the position's 2.0/1.7), and empty callback data is enough.Recommendation
Document that offer priority assumes liquidators do not partition targets to skip a better offer.
- Whole target
Maker callback in Request.consume() can invalidate the maxPrincipal cap checked by Retargetter.consume()
Context: Retargetter.sol#L211-L223, LibStorage.sol#L149-L175, Request.sol#L345-L369
Retargetter.consume()readsmaxPrincipal(positionManager)and passes it toLibStorage.checkOffer, which reverts withPrincipalCapExceededwhen PT supply plus outstanding authorizations plusptAmountexceeds the cap (LibStorage.sol:174-176). It then callsRequest.consume(), which invokes the maker'sonRequestConsumedbefore pulling principal and minting.maxPrincipalis derived from livecollateralAmountQuoted()anddebtAmount()(Retargetter.sol:688-711). A callback that liquidates the PositionManager (BorrowOffer, proportionalpreLiquidate, or native Morpho liquidation) shrinks the cap. Control returns toRequest.consume(), which transfers PT and mints PT/YT against a cap that no longer holds, so the operation takes on a Request liability plus its minimum tick of yield priced against capacity that is gone. The liquidation loss and bonus exist independently and are excluded from this issue's impact.// src/manager/rebalancer/Retargetter.sol:247-255 { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); operation_.checkOffer( request, offer.amount, offer.expectedReturn, ptAmount, maxPrincipal(operation_.positionManager) ); ytAmount = IRequest(request).consume(offer, signature, ptAmount); emit OfferConsumed(request, offer.maker, ptAmount, ytAmount); }// src/request/Request.sol:419-429 if (_syncWithdrawalStatus()) revert LibRequestErrors.AlreadyRepaid(); if (ptAmount == 0 || ptAmount > offer.amount) revert LibRequestErrors.InvalidPtAmount(); _validateOffer(offer, signature); ytAmount = offer.expectedReturn.mulDiv(ptAmount, offer.amount); if (offer.useCallback) { IRequestCallback(offer.maker).onRequestConsumed(offer, signature, ptAmount, ytAmount); } _asset().safeTransferFrom(offer.maker, address(this), ptAmount); _mint(offer.maker, ptAmount, ytAmount); _requestStorage().lastMintTimestamp = uint40(block.timestamp);Example
- ASYNC operation on a PositionManager holding wUSCC collateral against USDC debt;
maxPrincipalreads 500,000e6 USDC, PT supply 0. - Consumer calls
Retargetter.consume()for an offer ofptAmount = 400,000e6USDC withuseCallback = true.checkOfferpasses (400,000e6 <= 500,000e6). - Inside
onRequestConsumed, the maker liquidates the position; collateral drops and the recomputedmaxPrincipalfalls to 150,000e6 USDC. Request.consume()continues and mints 400,000e6 PT plus the proportional YT, 250,000e6 USDC of principal above the live cap.
Recommendation
Re-read
maxPrincipal(operation_.positionManager)afterIRequest(request).consume()returns and revert if the committed principal exceeds the updated cap.- ASYNC operation on a PositionManager holding wUSCC collateral against USDC debt;
Retargetter.maxPrincipal can exceed the one-trip repayment headroom of a fully deployed LTV-up operation
Context: Retargetter.sol#L392-L455, Retargetter.sol#L625-L647, Retargetter.sol#L765-L776
An ASYNC LTV-up operation caps Request funding at
maxPrincipal, which is the quoter's ideal principal scaled up byprincipalBufferBps(Retargetter.sol:711). Settlement prices the Request repayment from actual PT/YT supply and tick-quantized elapsed time (Retargetter.sol:832-844), while the debt leg that funds it is bounded by the PositionManager target LTV. The buffer inflates the amount the operation can owe but not the amount it can borrow back, so a cap-sized, fully deployed operation can owe more than the post-supply borrow capacity.For a non-owner caller the direction guard rejects the settlement rebalance when the resulting LTV is above target and did not improve (
Retargetter.sol:495-508), so the shortfall surfaces as a revert, not as an over-target position.// src/manager/rebalancer/Retargetter.sol:700-712 uint256 principal = IRetargetterQuoter(_QUOTER) .retargetPrincipal( collateralQuoted, debt, target, estimates.requestYieldRate, estimates.borrowRate, estimates.collateralYieldRate, estimates.subscriptionDuration, estimates.redemptionDuration ); return principal * (BPS + config_.principalBufferBps) / BPS; }// src/manager/rebalancer/Retargetter.sol:495-499 if (msg.sender != owner()) { uint256 ltvAfter = _positionManagerLtv(positionManager); if (ltvAfter > target && ltvAfter >= ltvBefore) { revert LibRetargetterErrors.AboveTargetLtv(ltvAfter, ltvBefore, target); }Example
Position: 10,000,000 USDC of quoted wUSCC collateral, 5,000,000 USDC debt, target LTV 0.7e18,
principalBufferBps = 100,maxYieldBps = 1000,horizon = 365 days,tickDuration = 1 days, zero rate estimates.- Ideal principal:
(7,000,000 - 5,000,000) * 1e18 / 0.3e18 = 6,666,666.666666USDC. maxPrincipalapplies the buffer:6,666,666.666666 * 10100 / 10000 = 6,733,333.333332USDC.- The full cap is subscribed and the wUSCC lands as collateral: quoted collateral becomes 16,733,333.33 USDC, so borrow capacity at target is
16,733,333.33 * 0.7 - 5,000,000 = 6,713,333.33USDC. - After one tick,
repaymentOwed= PT 6,733,333.333332 +ceil(673,333.333333 * 1 / 365)= 6,735,178.08 USDC. - Shortfall: 6,735,178.08 - 6,713,333.33 = 21,844.75 USDC.
repay()cannot be funded from the position in one trip, and the borrow that would cover it trips the direction guard.
Recommendation
Size
maxPrincipalagainst the worst permitted_owedrepayment (PT plus the fullmaxYieldBpsyield) and the post-supply target-LTV borrow capacity, so a cap-sized operation keeps enough one-trip settlement headroom. If the shortfall is accepted instead, document that cap-sized operations require reserved headroom, a top-up, or a second Fund trip before settlement.- Ideal principal:
A supply-only Retargetter rebalance lets holders of existing PositionManager shares withdraw Request-funded collateral
State
- Fixed
Issue #220
Severity
- Severity: Low
Submitted by
m4rio
Context: Retargetter.sol#L392-L455, PositionManagerRebalancing.sol#L45-L149, PositionManagerLP.sol#L41-L178, FacilityPositionManager.sol#L106-L134
A funded asynchronous Retargetter operation can convert Request principal into collateral. The operation can supply that collateral to a PositionManager without borrowing the debt asset needed to repay the Request.
Retargetter.rebalance()accepts the operation list from an owner orREBALANCER_ROLEholder.Retargetter.rebalance()resolves the requested amounts and forwards the list to the PositionManager:if (resolved.collateral > 0) { collateralAsset.safeApproveWithRetry(positionManager, resolved.collateral);}IPositionManager(positionManager).rebalance(resolved, address(this));The PositionManager dispatches each operation independently. A
SUPPLYoperation transfers collateral into a borrow module. It does not mint PositionManager shares for that Request-funded supply or record a liability to Request lenders. Unrelated fee accrual can still mint fee shares during the same call:if (operationType == RebalancingOperationType.SUPPLY) { position.supply(_collateralAsset, amount);}This supply increases
totalAssets()without corresponding share issuance for the supplied value. Fee accrual may mint unrelated fee shares, but no shares represent the Request-funded collateral. The rebalance loss check only handles a decrease intotalAssets(). The increase passes:if (totalAssetsAfter < totalAssetsBefore) { uint256 loss = totalAssetsBefore - totalAssetsAfter; // Check loss against maxRebalanceLoss.}Existing PositionManager shares now include the Request-funded collateral in their burn value.
PositionManager.burn()calculates collateral from total module collateral and the shares to burn.PositionManager.burn()does not reserve collateral for the active Retargetter operation.A normal Facility exit can therefore transfer the staged Request collateral to existing Facility LPs. The
FACILITATOR_ROLEholder callsburnManager()and resolves the intent. The LP then callsclaim().- A Facility LP deposits 10,000 collateral into an intent.
Facility.depositManager()creates a PositionManager position with 10,000 collateral and 4,000 debt.- The PositionManager has 6,000 NAV. The Facility holds the PositionManager shares.
- A Retargetter operation converts 5,000 of Request principal into collateral.
- The Retargetter rebalancer executes one
SUPPLYoperation for 5,000 collateral and noBORROWoperation. - PositionManager NAV increases from 6,000 to 11,000, but no shares are minted to represent the Request-funded increase.
- The Facility calls
burnManager()with its existing shares and tracked debt balance. - The Facility resolves the intent. The LP calls
claim().
The exit transfers
14,999.999999999999999997collateral to the LP. This amount includes4,999.999999999999999997of the 5,000 Request-funded units. Only 3 wei remain in the PositionManager.The Request still records the repayment obligation. After the Facility exit, the PositionManager can lack enough collateral to borrow the debt asset needed to repay the Request. Request lenders then depend on an external top-up or other recovery.
Recommendation
This has not a clear fix yet, the invariant that must be preserved:
A SUPPLY funded by Request capital must be matched by an equivalent BORROW of debt-asset back out of the PM, so the borrowed debt can repay the Request.Initial fix
Fixed in PR #209:
rebalance()now snapshots the position's net value over every module and revertsPositionValueIncreasedif it grew while the operation's Request is unrepaid, so bridge capital can only enter paired with an equivalent output leg.Second issue
I think we might have another issue now.
The new value-conservation gate only stays active while the Request is considered outstanding. After the deadline,
_bridgeOutstanding()callssyncRepaidStatus(), which marks the Request as repaid even when Retargetter has already pulled lender principal.For example:
- Request receives 3,000 units from the lender.
- Retargetter pulls 2,000 units.
- The Request expires.
syncRepaidStatus()marks it repaid.- The value gate is disabled.
- A rebalance folds the 2,000 pulled units into PositionManager NAV.
- The maker can recover only the 1,000 units still held by the Request.
The expiry path therefore treats “deadline passed” as “principal repaid.” Should the value gate should remain active until all pulled principal is returned or explicitly written off through a governed default process?
Fixed in PR #220: the value gate now keys on a Retargetter-local repaid flag set only by repay()/forceRepay() (the only possible setRepaid callers, since the Retargetter owns the Request), so deadline expiry never disarms it, and forceRepay now tolerates an expired Request (bounds enforced locally) so pulled principal can still be delivered back to lenders and the operation resolved.
One acknowledged caveat is documented: post-expiry redemptions price on the live Request balance, so holders should not burn their PT/YT before an expected late delivery lands.
Held management-fee credit is scaled by a deposit made at zero NAV
Context: LibView.sol#L65-L83, LibStorage.sol#L166-L207, PositionManagerLP.sol#L41-L83
heldManagementFeeAssetsis the management fee already charged, repaid out of the next positive performance basis before any performance fee mints. On every supply changerebaseSnapshotscales it by the supply ratio, guarded onprevCollat > 0:// src/libs/manager/LibStorage.sol:285-290 if (newSupply != prevSupply && prevCollat > 0) { uint256 heldManagementFeeAssets = self.heldManagementFeeAssets; if (heldManagementFeeAssets > 0) { self.heldManagementFeeAssets = heldManagementFeeAssets.mulDiv(newSupply, prevSupply); } }The comment at
LibStorage.sol:220-224states the guard exists to skip scaling when shares mint against a zero asset base, because the supply ratio no longer tracks any price. It tests gross collateral, and the caller passesprevCollat = totalAssetsBefore + debtBefore(PositionManagerLP.sol:236). A module atcollateral == debtis still included byLibView.totalAssets, whose filter iscollateral >= debt, so NAV is zero whileprevCollat = debt > 0and the guard passes.// src/libs/manager/LibView.sol:88-96 uint256 collateral = IBorrowPosition(modules[i]).totalCollateralQuoted(); uint256 debt = IBorrowPosition(modules[i]).totalBorrowed(); if (collateral >= debt) { amount += collateral - debt;With
totalAssetsBefore == 0the mint denominator collapses toVIRTUAL_ASSETS = 1, so a 1-unit deposit mints roughly the entire existing supply again:// src/manager/base/PositionManagerLP.sol:202-203 uint256 assetsAdded = totalAssetsAfter - totalAssetsBefore; uint256 sharesToMint = assetsAdded.convertToShares(_totalSupply, totalAssetsBefore, virtualShareOffset_, false);The doubled supply ratio doubles the credit, and the extra credit is subtracted from the performance basis at
PositionManagerBase.sol:223-224, cutting the fee recipient's take.Example
USDC debt asset and wUSCC collateral, both 6 decimals (
USCCFund.sol:50), sovirtualShareOffset = 1e12. Shares are 18 decimals. Reference held at 80% LTV during a drawdown:collateral 5,000,000 USDC of wUSCC (5e12) debt 5,000,000 USDC (5e12) NAV 0supply 1e24 (1,000,000 shares)heldManagementFeeAssets 10,000 USDC (1e10)lastDebt 4e12, lastTotalAssets 1e12 (lastCollat 5e12, LTV_ref 80%)deposit(1, 0)supplies 1 base unit of wUSCC (0.000001 wUSCC).totalAssetsAfter = 1, soassetsAdded = 1andsharesToMint = 1 * (1e24 + 1e12) / (0 + 1) = 1e24 + 1e12. Supply goes to2e24 + 1e12.rebaseSnapshotruns withprevCollat = 0 + 5e12 = 5e12, so the guard passes:heldManagementFeeAssets = 1e10 * (2e24 + 1e12) / 1e24 = 2e10, i.e. 10,000 USDC becomes 20,000 USDC. Carry scales the same way (1e12to2e12 + 1), re-anchoring the reference atlastDebt = 3e12 - 1,lastCollat = 5e12 + 1, about 60% LTV.- wUSCC appreciates to 10,000,000 USDC of collateral against the same 5,000,000 USDC debt. Basis (
PositionManagerBase.sol:193-195):mulDivUp(3e12 - 1, 1e13, 5e12 + 1) - 5e12 = 1e12, a 1,000,000 USDC basis. - Performance fee is charged on
basis - managementFeeAssets - heldManagementFees_. The inflated credit shrinks that base by 10,000 USDC, so atMAX_PERFORMANCE_FEE = 5000BPS (LibConstants.sol:41) the fee recipient loses 5,000 USDC.
The deposit must stay small. A larger one pushes
carryabovenewDebt, which clampslastDebtto the bootstrap sentinel and makes the next accrual clear the accumulator.Recommendation
Gate the scaling on pre-flow NAV instead of gross collateral:
if (newSupply != prevSupply && prevCollat > prevDebt) {The ratio still detaches from price just above zero NAV, so also cap the scaled value at the pre-flow credit when post-flow NAV per share falls below pre-flow.
Splitting one gain across many checkpoints can erase the performance fee
Context: PositionManagerBase.sol#L180-L225, PositionManagerBase.sol#L257-L305, PositionManagerRebalancing.sol#L49-L106
A positive performance basis sets
advanceReference = truebefore the performance fee is represented as shares. The fee rounds down once during the BPS multiplication (PositionManagerBase.sol:223-224) and again duringconvertToShares(..., false)(PositionManagerBase.sol:257-266).uint256 scaledLastDebt = _lastDebt.mulDivUp(currentCollat, lastCollat);if (scaledLastDebt > currentDebt) { basis = scaledLastDebt - currentDebt; advanceReference = true; // <-- set here, before fee share conversion}First round down.
// PositionManagerBase.sol:223-224if (fd.performanceFee > 0 && basis > managementFeeAssets + heldManagementFees_) { totalFeeAssets += (basis - managementFeeAssets - heldManagementFees_).mulDiv(fd.performanceFee, BPS);}Second.
// PositionManagerBase.sol:260-261performanceFeeShares = totalFeeAssets.convertToShares(totalSupply_, totalAssets_ - totalFeeAssets, _storage.virtualShareOffset, false);_accrueFees()mints only when the resultingfeeSharesis nonzero, but it still advanceslastTotalAssetsandlastDebtwheneveradvanceReferenceis true (PositionManagerBase.sol:289-305). A gain whose fee rounded to zero shares is therefore consumed by the new reference and cannot contribute to a later fee.rebalance()calls_accrueFees()before processing its funding and operation list. AREBALANCER_ROLEholder can consequently submit zero-funding, zero-operation rebalances after successive gain observations, subject to the configured cooldown. If every interval produces less than one raw fee share, every checkpoint mints zero even though one checkpoint over the same aggregate gain would mint a material fee.Example
Assume one cumulative gain would entitle the fee recipient to 10 raw fee shares if accrued once. If the same gain is observed across 20 equal intervals, each interval economically corresponds to 0.5 raw fee share and
convertToShares(..., false)rounds it down to zero. Every checkpoint then advances the performance reference and discards that fractional entitlement. After all 20 checkpoints, the fee recipient has received zero shares, whereas one checkpoint over the same cumulative gain would have minted 10 shares.The standalone impact is lost performance-fee revenue. Direct profit requires the checkpoint actor to be aligned with the existing LP cohort; otherwise the action primarily griefs the fee recipient. The path also requires a nonzero performance fee, high NAV per raw share, and a feasible checkpoint cadence. It does not expose unrelated user principal.
Recommendation
Carry fractional performance-fee entitlement across checkpoints in fee-asset or higher-precision share units. Do not advance the performance reference until any rounded remainder has been conserved. As defense in depth, restrict economically empty checkpoints and revoke or epoch operational roles during ownership handoff.
Initial Fix
Fixed in PR #211: the reference is held when a nonzero performance entitlement converts to zero fee shares, so the basis accumulates across checkpoints until it mints at least one share.
Second Issue
I think we might have added a new issue if i see it correctly:
A deposit scales a held performance entitlement into a fee on fresh principal
Context: LibStorage.sol#L336-L348, PositionManagerLP.sol#L41-L75, PositionManagerLP.sol#L194-L239
The fix correctly holds the performance reference when a positive entitlement rounds down to zero fee assets or zero fee shares. This prevents repeated checkpoints from consuming an entitlement that was never minted.
When a capital flow subsequently reaches
rebaseSnapshot, however, the held positive gain is multiplied by the change in share supply:if (prevCarry == 0) { gain = FixedPointMathLib.zeroFloorSub(scaledRefDebt, prevDebt) .min(FixedPointMathLib.zeroFloorSub(prevCollat - prevDebt, self.lastTotalAssets)); gain = gain.mulDiv(newSupply, prevSupply);}gainis an asset-denominated performance basis. Increasing share supply through a deposit does not represent profit and should not increase the total fee entitlement.deposit()accrues fees before transferring the new capital. If the existing entitlement rounds to zero, the reference remains held and no fee shares are minted. The deposit then mints shares and callsrebaseSnapshot, where the new supply ratio scales the old gain. The next accrual can consequently mint a performance fee against the incoming principal.Example
Assume a PositionManager has three atoms of NAV and one atom of debt. A third party repays one debt atom through Morpho, producing a one-atom positive basis. At a 15% performance fee, the entitlement rounds to zero fee assets, so the reference is held as intended.
A subsequent pure deposit of
1e18increases the share supply. The current rebase scales the one-atom basis to:266666666666666667The next accrual mints:
33333333333333333 fee sharesThe fresh depositor’s proportional claim falls from:
999999999999999999to:
959999999999999999This is a loss of
40000000000000000, or 4% of the deposited principal, despite no profit occurring after the deposit.Will double check.
Second fix
Fixed in PR #218: the held entitlement is now kept nominal across flow rebases (like the held management-fee accumulator, the supply ratio being a value-detached lever in both directions), falling back to the supply-scaled read only once the gain outgrows half the post-flow NAV so a draining exit cannot truncate the high-water mark to zero and re-read the residual NAV as basis.
Third Issue
I think now we might have another issue, we now stop deposits from scaling a held performance gain into a fee on fresh principal. However, the new half-NAV fallback can still scale that gain down during an exit.
Example:
- NAV starts at 10
- Share supply starts at 3
- Held performance gain is 7
- A deposit of 11 changes NAV and supply to 21 and 7, while the gain stays at 7
- Withdrawing the same 11 restores NAV and supply to 10 and 3, but reduces the gain to 3
The deposit and withdrawal restore the vault state, but the held fee entitlement loses 4 units. The remaining LPs benefit because less performance fee will be minted later.
Third Fix
Acknowledged and documented in PR #219: the round trip can only shed fee-recipient value (the reference is read by fee accrual alone, and grinding the gain raises the mark), the zero-share hold bounds any genuine entitlement in the above-half-NAV region below one raw share's worth of fees (dust outside atom-scale vaults), and the window gains large enough to reach the fallback never mint on re-entry anyway, so we prefer this residual over a half-NAV cap that would charge the stayers for the departed shares' slice.
Performance reference is rebased after outgoing token transfers, so a token callback can move the basis
Context: PositionManagerLP.sol#L111-L157, PositionManagerRebalancing.sol#L71-L108
PositionManagerLP.burn()sends collateral to the caller atPositionManagerLP.sol:171and only then calls_rebaseReference()atPositionManagerLP.sol:176.PositionManagerRebalancing.rebalance()does the same: excess collateral and debt go toreceiveratPositionManagerRebalancing.sol:100-101, rebase atPositionManagerRebalancing.sol:113._rebaseReference()reads live aggregates via_storage.totalAssets()(PositionManagerBase.sol:330), so anything that changes module collateral or debt between the transfer and the rebase lands in the new reference.A callback-enabled token hands control to the receiver during those transfers.
nonReentrantblocks reentry into the PositionManager, but not a direct call to Morpho. Morpho supply/repay are permissionless on behalf of a module, so the receiver can raise collateral or cut debt for a PositionManager module without touching the PositionManager, and the rebase then snapshots that state as the new performance basis. This shifts the basis for a later performance fee up or down.// src/manager/base/PositionManagerLP.sol:169-176 // Send collateral to caller if (collateral > 0) { _storage.metadata.collateralAsset.safeTransfer(msg.sender, collateral); } // Rebase the performance reference across the flow so the exiting shares take their // proportional slice of any carried pending basis with them. _rebaseReference(totalAssetsBefore, debtBefore, _totalSupply);// src/manager/base/PositionManagerRebalancing.sol:100-113 collateralExcess = _collateralAsset.safeTransferAll(receiver); debtExcess = _debtAsset.safeTransferAll(receiver); // Record rebalance timestamp for cooldown enforcement // Safe: block.timestamp fits in uint40 for ~35,000 years // forge-lint: disable-next-line(unsafe-typecast) _storage.rebalanceConfig.lastRebalanceTimestamp = uint40(block.timestamp); emit Rebalanced(receiver, data.collateral, data.debt, collateralExcess, debtExcess); // Rebase the performance reference to the post-rebalance state. A rebalance is a flow, not // a gain: the share supply is unchanged, so any carried pending basis is preserved as-is and // the rebalance neither crystallizes a performance fee nor writes off accrued debt carry. uint256 totalAssetsAfter = _rebaseReference(totalAssetsBefore, debtBefore, ERC20.totalSupply());Recommendation
Snapshot the final module aggregates before the outgoing transfers and rebase the reference from that snapshot.
Retargetter binds to an unauthenticated PositionManager address
Context: Retargetter.sol#L197-L234, Retargetter.sol#L786-L826, Retargetter.sol#L440-L512
startRetargettingtakes thepositionManageraddress as a caller-supplied argument and stores it as the operation's binding (Retargetter.sol:197-231)._checkStartis the only gate, and it never checks that the address is a real PositionManager:// src/manager/rebalancer/Retargetter.sol:791-800 function _checkStart(RetargetterOperation storage operation_, address positionManager, address fund, uint256 amount) internal view { if (operation_.positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); if (operation_.orderLive) revert LibRetargetterErrors.OrderActive(); _checkPair(positionManager); if (!LibStorage.whitelistsStorage().funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); if (amount > maxPrincipal(positionManager)) revert LibRetargetterErrors.PrincipalCapExceeded(); }Both checks that touch
positionManagerread values the address reports about itself._checkPaircallsassets()on it, andmaxPrincipalcallscollateralAmountQuoted(),debtAmount(), andconfig()on the same address, then feeds those into the quoter to size the principal cap (Retargetter.sol:688-712):// src/manager/rebalancer/Retargetter.sol:820-826 function _checkPair(address positionManager) internal view { RetargetterAssets storage assets_ = LibStorage.assetsStorage(); (address collateralAsset, address debtAsset) = IPositionManager(positionManager).assets(); if (collateralAsset != assets_.collateralAsset || debtAsset != assets_.debtAsset) { revert LibRetargetterErrors.AssetMismatch(); } }A contract that returns the configured asset pair and any collateral/debt numbers passes both.
rebalancethen approves the bound address for the pulled principal and calls itsrebalance():// src/manager/rebalancer/Retargetter.sol:487-492 if (resolved.collateral > 0) collateralAsset.safeApproveWithRetry(positionManager, resolved.collateral); if (resolved.debt > 0) debtAsset.safeApproveWithRetry(positionManager, resolved.debt); IPositionManager(positionManager).rebalance(resolved, address(this)); if (resolved.collateral > 0) collateralAsset.safeApprove(positionManager, 0); if (resolved.debt > 0) debtAsset.safeApprove(positionManager, 0);The post-call LTV checks at
Retargetter.sol:496-508read_positionManagerLtv(positionManager)and_moduleLtv()on modules the same contract returns, so they are self-reported too.Funds and flash-loan modules are owner-whitelisted (
setFundatRetargetter.sol:617-628,setFlashLoanModuleat:636). PositionManagers are not, even though they are the address that receives the approval.Example
USDC debt asset and wUSCC collateral, both 6 decimals (
USCCFund.sol:50).EvilManageris a contract returning the configured pair fromassets(), fabricated healthy values fromcollateralAmountQuoted()/debtAmount()/config(), and an emptyborrowModules().REBALANCER_ROLEcallsstartRetargetting(evilManager, 2,000,000 USDC (2e12), ...)._checkPairpasses on the reported pair.EvilManagerreports 10,000,000 USDC of quoted collateral against 4,000,000 USDC of debt, somaxPrincipalreturns a cap above the requested principal and_checkStartpasses.CONSUMER_ROLEcallsconsume()for 2,000,000 USDC of PT. The principal gate re-checksmaxPrincipal(evilManager), which the same contract answers.REBALANCER_ROLEcallspullRequestFunds(2,000,000 USDC), moving lender USDC from the Request into the Retargetter.REBALANCER_ROLEcallsrebalance()withresolved.debt = 2,000,000 USDC. The Retargetter approvesEvilManagerfor 2e12 and calls itsrebalance(), which runstransferFrom(retargetter, attacker, 2e12).EvilManagerreturns healthy LTVs, so the direction checks pass. The Request still records a 2,000,000 USDC repayment obligation with a 90-day deadline and no assets behind it.
Lenders lose the full 2,000,000 USDC of principal.
Recommendation
Gate the PositionManager binding the same way funds and flash-loan modules are gated: an owner-curated whitelist checked in
_checkStart, sostartRetargettingcan only bind an address governance has approved.A
PositionManagerFactorydeployment check is weaker on its own, since the factory is permissionless, but it does pin the implementation. Combining the two (factory-deployed and whitelisted) gives both an approved instance and a known implementation.Shared Midas redemption capacity can be exhausted after the Fund pays its bond
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Context: MidasFund.sol#L300-L376, MidasFund.sol#L455-L488, MidasFund.sol#L528-L540, MidasFund.sol#L800-L856
A Midas redeem is split across two separately submitted commits. The first commit pays the Repay-and-Redeem bond and records
bondPaid; only afterPAYMENT_ROLEcallsunlockInstantRedeem()can the second commit call the external Midas redemption vault:// MidasFund.sol:368-372} else if (!$.instantRedeemUnlocked) { _legAmount = _commitBondLeg($, _currentOrderId, order.input);} else { _legAmount = _commitRedeemLeg($, order);} // MidasFund.sol:812-817uint256 _bondAmount = _input * $.bondConfig.amount / BPS;IWrappedAsset($.wrappedShare).burn(msg.sender, address(this), _bondAmount);$.mToken.safeTransfer(_recipient, _bondAmount);$.bondPaid = _bondAmount;The bond leg does not reserve the external redemption vault's daily capacity, payment-token allowance, or downstream liquidity. Those resources may be shared with ordinary users of the same Midas product. A third party can therefore consume the remaining shared capacity between the two commits. The Fund's second commit then reaches
redeemInstant()and reverts:uint256 _redeemAmount = order.input - $.bondPaid;IWrappedAsset($.wrappedShare).burn(msg.sender, address(this), _redeemAmount);_mToken.safeApproveWithRetry(_redemptionVault, _redeemAmount);IMidasRedemptionVault(_redemptionVault).redeemInstant( _asset, _redeemAmount, _minOutput * $.assetScale);The transaction reverts atomically, but the earlier bond payment remains final.
cancel()is unavailable oncebondPaid > 0, and recovery can only end the unsettled order while relying on Midas to return the bond off-band. The Fund may retry after limits reset, so the primary impact is delayed settlement and conditional bond forfeiture rather than immediate theft.Example
Assume the selected Midas route has a 2,000,000 mGLOBAL daily redemption limit. A Facility begins a 1,000,000 mGLOBAL redeem with a 5% bond. The first commit transfers 50,000 mGLOBAL to the bond recipient, leaving 950,000 mGLOBAL for the redemption leg.
Before that leg is submitted, another allowed Midas user consumes enough of the shared daily limit to leave less than 950,000 mGLOBAL of capacity. The Fund's
redeemInstant()call reverts. The Facility must wait for capacity to reset or enter recovery, and the 50,000 mGLOBAL bond is not restored on-chain.Recommendation
There is not much that can be done to prevent this other than documenting the risk.
FACILITATOR can burn an intent's entire Midas share balance by overstating the redeem input
Context: FacilityFunds.sol#L39-L73, FacilityFunds.sol#L94-L119, MidasFund.sol#L300-L376, MidasFund.sol#L800-L856
FacilityFunds.create()letsFACILITATOR_ROLEchoose the orderamount, which becomesorder.input, without bounding it against the Midas shares attributed to the selected intent:function create(uint256 id, uint256 amount, uint256 minAmountOut, Mode mode) external onlyRoles(FACILITATOR_ROLE) returns (Order memory order){ order = Order({ mode: mode, owner: address(this), receiver: address(this), input: amount, output: minAmountOut, salt: keccak256(abi.encode(address(this), block.timestamp, id)) }); IFund(_fund).create(order);}For a Midas redeem, the first commit calculates the bond from that caller-selected input rather than from the intent's actual share balance:
uint256 _bondAmount = _input * $.bondConfig.amount / BPS;IWrappedAsset($.wrappedShare).burn(msg.sender, address(this), _bondAmount);$.mToken.safeTransfer(_recipient, _bondAmount);$.bondPaid = _bondAmount;The Midas Fund returns
order.inputas the committed amount for both legs. Consequently, the Facility's_committedAmount == _order.inputcheck passes even when the first leg only consumed the computed bond. Its balance snapshot then records the real token decrease, but does not reject an order whose declared input exceeds the selected intent's custody.A sufficiently overstated input makes the bond equal the entire real share balance. The first leg succeeds and transfers all shares to the bond recipient. After
unlockInstantRedeem(), the second leg attempts to burnorder.input - bondPaid, which does not exist and reverts.cancel()is unavailable because the bond has already been paid. Recovery can end the order, but the bond is only restored if Midas returns it off-band.Example
The selected intent has 100 wrapped mGLOBAL shares and the configured bond is 5%. The FACILITATOR creates a redeem order with
order.input = 2,000.- Bond:
2,000 * 5% = 100shares. The first commit succeeds and transfers all 100 real shares to the bond recipient. - Remaining redeem amount:
2,000 - 100 = 1,900shares. The second commit reverts because the Facility no longer holds them. - With the honest
order.input = 100, the bond would be only 5 shares and 95 shares would remain redeemable.
The FACILITATOR does not receive the bond directly and the impact is limited to a role-authorized operation, but the selected intent can lose its entire staged Midas share balance rather than the configured 5% bond.
Recommendation
In
FacilityFunds.create(), require the redeemamountto be no greater than the selected intent's internally accounted Fund-share balance. As defense in depth,MidasFundcan also reject a redeem whose fullorder.inputexceeds the caller's wrapped-share balance, but the per-intent Facility check is required because the Facility may custody shares for several intents in one address.- Bond:
Permissioned mToken deposits can become stuck when vault greenlisting is disabled
State
- Acknowledged
Severity
- Severity: Low
Submitted by
m4rio
Description
MidasFund._checkVaultAccess()only validates the Fund andWrappedAssetagainst the Midas greenlist whenvault.greenlistEnabled()is true (MidasFund.sol:940-953). This incorrectly assumes the vault flag also controls the mToken's transfer restrictions.For permissioned mTokens such as mGLOBAL,
mTokenPermissioned._beforeTokenTransfer()independently checks every nonzero sender and recipient (mTokenPermissioned.sol:22-32). Therefore, a configuration can have:vault.greenlistEnabled() == false;- the Fund greenlisted, allowing mGLOBAL to be minted to it; and
- the
WrappedAssetnot greenlisted.
In that state,
create()andcommit()both pass because the Fund skips the role checks. Midas can then approve the deposit request and mint mGLOBAL to the Fund. However,unlock()calls_wrapTo(), andWrappedAsset.mint()pulls mGLOBAL from the Fund into the wrapper. The permissioned token rejects the non-greenlisted wrapper, sounlock()reverts.The order remains in the dynamic
UNLOCKINGstate with the mGLOBAL held by the Fund. The deposit recovery path explicitly rejects an approved deposit inUNLOCKING(MidasFund.sol:480-486), so completion requires Midas to greenlist the wrapper or an administrative upgrade/configuration repair. No attacker profit is established, so this is a Low settlement-liveness issue.Proof of Concept
- Configure a permissioned mGLOBAL token and set
depositVault.greenlistEnabled(false). - Grant mGLOBAL's greenlisted role to the Fund, but not to the
WrappedAsset. - Create and commit a normal deposit order. Both calls succeed because
_checkVaultAccess()skips the role checks. - Process the Midas request so mGLOBAL is minted to the Fund.
- Call
unlock(order).
The call reaches:
function _wrapTo(MidasFundStorage storage $, address _receiver, uint256 _amount) internal { address _wrappedShare = $.wrappedShare; $.mToken.safeApproveWithRetry(_wrappedShare, _amount); IWrappedAsset(_wrappedShare).mint(_receiver, _amount);}WrappedAsset.mint()attemptsmGLOBAL.transferFrom(Fund, WrappedAsset, amount). The permissioned transfer hook checks both addresses and reverts because the wrapper is not greenlisted. The order cannot userecovering()because its dynamic state is alreadyUNLOCKING.Recommendation
Make sure Midas will always whitelist the wrapped asset.
Informational25 findings
Small hardening and documentation nits
Context: MorphoBorrowPosition.sol#L790-L853, LibBorrowOffers.sol#L140-L204, Retargetter.sol#L68-L76, PositionManagerLP.sol#L190-L235, IPositionManagerAdmin.sol#L95-L108, MorphoFlashLoanRequest.sol#L238-L250, IRetargetter.sol#L51-L60, IRetargetter.sol#L276-L291, Retargetter.sol#L631-L653, BorrowOffersRegistry.sol#L129-L156, LibBorrowOffers.sol#L140-L188, MorphoBorrowPosition.sol#L1136-L1164, Request.sol#L419-L422, OfferReceiver.sol#L57-L66, BorrowOffersRegistry.sol#L98-L106, BorrowOffersRegistry.sol#L213-L220, BorrowOffersRegistry.sol#L262-L269, OfferReceiver.sol#L129-L156
Minor API and documentation items that can confuse integrations or future maintenance.
-
offerCount()(MorphoBorrowPosition.sol:947) returnspopCount(liveBits)(LibBorrowOffers.sol:499), so it also counts offers that expired but were not yet pruned or revoked, whileIBorrowOffers.sol:105documents it as the number of currently-live offers. -
offer()andoffers()(MorphoBorrowPosition.sol:952,MorphoBorrowPosition.sol:961) return the same allocated-but-expired slots, andIBorrowOffers.sol:108-113describes the results as live offers. -
_alloc()(LibBorrowOffers.sol:149) andconsume()(LibBorrowOffers.sol:197) delete expired offers without emitting a per-offer pruning event, so indexers see slots vanish with no receipt. -
preLiquidate()(MorphoBorrowPosition.sol:344) returns only seized collateral and repaid assets, so one call that consumes several offers exposes the per-offer identities through theOfferConsumedevents (LibBorrowOffers.sol:214) alone. -
_QUOTERis immutable (Retargetter.sol:86), so replacing the quoting math requires deploying a new Retargetter. -
setLtvdocuments its parameter asltv_(IPositionManagerAdmin.sol:97) whilesetFeeDatanames a different quantityLTV_prev(IPositionManagerAdmin.sol:107) in the same interface, with no note that the two LTVs are unrelated. -
onMorphoFlashLoan()zeroes raw debt atMorphoFlashLoanRequest.sol:259-260before Morpho pulls the approved repayment, and the comment there does not state that a failed pull reverts the whole transaction. -
Retargetter inherits Solady Multicallable at Retargetter.sol:71 and exposes multicall(bytes[]), but IRetargetter.sol:51-60 and :276-291 do not declare it. Integrations that generate an ABI from IRetargetter therefore miss a live external entry point and can build an incomplete route or wrapper.
-
Request.consume at Request.sol:419-422 accepts a partial fill and prorates the YT mint, but _validateOffer records the consumed offer nonce in the maker-wide nonce at OfferReceiver.sol:143. The consumed offer and every lower-nonce offer become invalid, so the unfilled remainder needs a fresh signature. The public NatSpec documents proration but not this single-use behavior.
-
BorrowOffersRegistry.sol:268-269storesminOfferBonusBpsPlusOnewith a biased+1sentinel and decodes it withstoredBonus - 1. This is harder to reason about than an explicitexistsflag; using a boolean would make the configured-versus-default state unambiguous and avoid the+1/-1logic. -
initialize()storesdepositVaultwithout emittingDepositVaultUpdated(MidasFund.sol#L195-L241). The factory'sFundCreatedevent already exposes the initial vault binding, so this is event-consistency and indexer hardening rather than missing onchain observability. -
MidasFundStoragecould pack some small fields alongside addresses to reduce storage slots (MidasFund.sol#L121-L166). This is a gas/layout optimization only; because the Fund is upgradeable, any repacking must preserve the established ERC-7201 storage layout and prove that narrowed field widths cover all valid values.
Recommendation
Fix the above issues.
-
A revoked mint authorization permanently starts the Retargetter loan clock
Context: Retargetter.sol#L230-L248, Retargetter.sol#L304-L312, LibStorage.sol#L149-L214, LibStorage.sol#L260-L276
The owner or a
CONSUMER_ROLEaccount creates and revokes Request mint authorizations throughRetargetter.authorizeMinting(Retargetter.sol:264). A nonzero authorization routes intocheckOffer, which callscheckConsumptionWindow; withstartedAt == 0that storesblock.timestamp(LibStorage.sol:199) before any broker transfers capital or mints PT/YT. Repayment prices every later yield-token obligation from that origin.The same caller can then revoke with
authorizeMinting(to, 0, 0). The zero-amount path skips every gate (Retargetter.sol:276), so the operation can sit at zero PT supply, zero YT supply, and no pending authorizations whilestartedAtstays set. AftertickThresholdelapses, consume and nonzero authorizations revertConsumptionWindowClosed(LibStorage.sol:200-202). Nothing resetsstartedAtshort ofclearOperation(LibStorage.sol:271), which only runs atresolve(Retargetter.sol:351).The empty operation can still be settled through
repay(owed is zero, so it marks the Request repaid) andresolve, then restarted. The owner can also widen the livetickThresholdwithin the configured bounds, which changes tick-based repayment economics. Neither a permanent lock nor direct fund loss follows: the cost is a Consumer-triggered operational restart.// src/manager/rebalancer/Retargetter.sol:264-283 function authorizeMinting(address to, uint128 ptAmount, uint128 ytAmount) external onlyOwnerOrRoles(CONSUMER_ROLE) nonReentrant { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); // Replace semantics: drop the account from the set so the principal gate sizes the new // amounts as fresh capital; a full revocation (both amounts zero) skips every gate, since // it only shrinks exposure and must stay available once the window has closed EnumerableSetLib.AddressSet storage accounts = operation_.authorizedAccounts; accounts.remove(to); if (ptAmount != 0 || ytAmount != 0) { operation_.checkOffer(request, ptAmount, ytAmount, ptAmount, maxPrincipal(operation_.positionManager)); // The capacity bound keeps every loop over the set within gas reach; see the constant accounts.add(to, MAX_AUTHORIZED_ACCOUNTS); } IRequest(request).authorizeMinting(to, ptAmount, ytAmount); emit MintingAuthorized(request, to, ptAmount, ytAmount); }// src/libs/manager/rebalancer/LibStorage.sol:190-203 function checkConsumptionWindow(RetargetterOperation storage self) internal { if (self.consumptionClosed) revert LibRetargetterErrors.ConsumptionWindowClosed(); uint256 startedAt = self.startedAt; if (startedAt == 0) { if (block.timestamp + MIN_DEADLINE_BUFFER > self.repaymentDeadline) { revert LibRetargetterErrors.DeadlineTooClose(); } // Safe: block.timestamp fits in uint40 for ~35,000 years // forge-lint: disable-next-line(unsafe-typecast) self.startedAt = uint40(block.timestamp); } else if (block.timestamp > startedAt + configStorage().tickThreshold) { revert LibRetargetterErrors.ConsumptionWindowClosed(); } }Recommendation
Reset
startedAtwhen PT/YT supplies and pending mint authorizations are all zero after a revocation. If the one-way clock is intentional, document that any nonzero authorization starts it and revocation does not rewind it.One-wei repayment flips a module into the management-fee basis for the whole checkpoint interval
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
m4rio
Context: LibView.sol#L65-L83, MorphoBorrowPosition.sol#L478-L494, PositionManagerBase.sol#L65-L223
LibView.totalAssets()includes a borrow module's full quoted collateral intotalCollateralonly whencollateral >= debt(LibView.sol:90-96).MorphoBorrowPosition.totalBorrowed()floors borrow shares to assets withtoAssetsDown(MorphoBorrowPosition.sol:605), so the comparison is a one-wei cliff.A module one debt unit above its quoted collateral contributes zero to
currentCollat. Anyone can repay that unit on Morpho, which moves the module into the fee basis.PositionManagerBase._pendingFees()then charges the management fee on the newcurrentCollatoverelapsed = block.timestamp - lastFeeAccrualTimestamp(PositionManagerBase.sol:213-217), the full checkpoint interval, because storage keeps no per-module inclusion time. This is a fee-eligibility cliff, separate from Retargetter's prorated-YT ceiling.// src/libs/manager/LibView.sol:85-97 address[] memory modules = ps.borrowModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; ++i) { uint256 collateral = IBorrowPosition(modules[i]).totalCollateralQuoted(); uint256 debt = IBorrowPosition(modules[i]).totalBorrowed(); if (collateral >= debt) { amount += collateral - debt; totalDebt += debt; totalCollateral += collateral; } else { hasBadDebt = true; } }// src/manager/base/PositionManagerBase.sol:213-217 if (fd.managementFee > 0) { uint256 elapsed = block.timestamp - _storage.lastFeeAccrualTimestamp; managementFeeAssets = currentCollat.mulDiv(fd.managementFee * elapsed, BPS * SECONDS_PER_YEAR); managementFeeAssets = managementFeeAssets.min(totalAssets_); }Example
Debt asset USDC (6 decimals), collateral wUSCC (6 decimals), quoted 1:1 for readability.
- Healthy modules provide 10,000,000 USDC of quoted collateral (1e13).
- An excluded module holds 5,000,000 wUSCC (5e12), quoted at 5,000,000 USDC, against 5,000,000.000001 USDC of debt, one base unit above its boundary.
- One day passes (86,400s) at a 100 BPS annual management fee.
- A caller repays 0.000001 USDC (1 base unit) on Morpho, so
collateral >= debtholds again. - The next accrual runs on
currentCollat = 15,000,000 USDCfor the full day instead of 10,000,000:15e12 * 100 * 86400 / (10000 * 365 days)= 410,958,904 base units (410.958904 USDC) instead of 273,972,602 (273.972602 USDC).
The extra 136.986302 USDC reduces the remaining LPs' value and benefits the configured fee recipient.
Recommendation
Charge a module only from the first checkpoint where it is observed as fee-eligible. Alternatively, document that management fees use checkpoint-end eligibility for the full elapsed interval.
Fund adapters assume exact-transfer assets without documenting it
Context: IFund.sol#L48-L103, CentrifugeFund.sol#L147-L219, CentrifugeFund.sol#L421-L459
Fund adapters move token amounts between the local contract and the venue with exact transfer semantics.
CentrifugeFund.commitpullsorder.inputatCentrifugeFund.sol:221and then approves and requests that sameorder.inputatCentrifugeFund.sol:222-223, so any shortfall in the received amount breaks the venue request. Supported assets therefore need transfer behavior matching adapter accounting, and neitherIFundnor the deployment material states that requirement.Native-decimal asset amounts differ from base-18 share or quote units, and wrapper conversions can leave a bounded raw-atom remainder. Exact-transfer tokens bound that remainder. Fee-on-transfer or rebasing behavior invalidates the unit invariant and can cause loss beyond the bounded residue.
Scope: this is about the missing documented assumption, no unsupported token or transfer-tax token is currently admitted or deployed.
// src/funds/centrifuge/CentrifugeFund.sol:218-224 if (order.mode == Mode.DEPOSIT) { // Pull asset from depositor, approve vault, request deposit address _asset = $.asset; _asset.safeTransferFrom(msg.sender, address(this), order.input); _asset.safeApproveWithRetry(_vault, order.input); ICentrifugeVault(_vault).requestDeposit(order.input, address(this), address(this)); _asset.safeApproveWithRetry(_vault, 0);Recommendation
Document exact-transfer semantics and unit boundaries in
IFund, adapter NatSpec, and deployment materials. Alternatively, publish a supported-asset list that excludes fee-on-transfer, rebasing, and callback tokens.Facility balance snapshots credit PositionManager fee-mint shares to the resolving intent
Context: FacilityPositionManager.sol#L31-L134, FacilityPositionManager.sol#L191-L202, PositionManagerBase.sol#L65-L223
depositManager,withdrawManager, andburnManagersnapshot the Facility's collateral, debt, and PositionManager-share balances in_initialPmParameters(FacilityPositionManager.sol:192-194), then_commitSnapshotsassigns each full balance delta to the selected intent (FacilityPositionManager.sol:206-217,LibIntent.sol:342-361).// src/facility/base/FacilityPositionManager.sol:191-194 // take snapshots before the operation collateralSnapshot = LibIntent.takeBalanceSnapshot(collateralAsset); debtSnapshot = LibIntent.takeBalanceSnapshot(debtAsset); sharesSnapshot = LibIntent.takeBalanceSnapshot(positionManager);Every
IPositionManagerflow calls_accrueFees()before share balances move (PositionManagerLP.sol:53,:99,:136). With pending fees,_accrueFees()mints shares tofeeData.feeRecipient(PositionManagerBase.sol:292-296). When that recipient is the Facility, the observed share delta is the operational delta plus the fee mint, andcommitBalanceSnapshot()records the full signed net change against the selected intent. Deposits produce a positive operational delta; withdrawals and burns produce a negative one. In the deposit path the intent's LPs can claim the fee shares after resolution.// src/manager/base/PositionManagerBase.sol:289-296 uint256 feeShares = managementFeeShares + performanceFeeShares; // Mint fee shares if (feeShares > 0) { address feeRecipient = LibStorage.positionManagerStorage().feeData.feeRecipient; _mint(feeRecipient, feeShares); emit IPositionManagerLP.FeesAccrued(feeRecipient, feeShares); }Preconditions:
feeRecipient == Facility, pending fee shares, a resolving intent using that PositionManager, and a normalFACILITATOR_ROLEmanager operation. A fixed-block check of 25 PositionManagers found external Safe recipients; that check did not establish current fleet exposure.No extra shares are minted and no collateral moves. Pending fee shares shift from protocol fee custody to one intent's LPs, bounded by the fees pending at the checkpoint, repeatable while the configuration stands. Share conversion floors and fee rounding leave a bounded remainder on top of that.
Example
USDC PositionManager (
virtualShareOffset = 1e12),totalAssets = 1,000,000 USDC(1e12),totalSupply = 1e24shares,managementFee = 200BPS (theMAX_MANAGEMENT_FEEcap) with one year elapsed andperformanceFee = 0, assuming supply still sits at the1e12shares-per-USDC-unit parity a vault starts at._pendingFees()charges 20,000 USDC;convertToShares(2e10, 1e24, 1e12 - 2e10, 1e12, false)mints20,408,163,265,305,705,955,851shares (~20,408.16 shares), worth 20,000 USDC once the mint lands.depositManagersnapshots the share balance before the operation, so the recorded delta is the deposit's operational shares plus that fee mint.- The intent's LPs claim the extra ~20,408.16 shares, 20,000 USDC of fee value, at resolution.
Recommendation
Attribute operational share deltas separately from fee mints so
commitBalanceSnapshot()credits only operation shares to the resolving intent. Alternatively, document that snapshots absorb pending fee shares whenfeeRecipient == Facility.Zero-share fee accrual is documented only in internal comments, not on the feeData interface
Context: PositionManagerBase.sol#L65-L114, PositionManager.sol#L176-L205, IPositionManager.sol#L79-L98
IPositionManager.feeData()documents each of the six returned fields, andlastDebt()documents the zero bootstrap sentinel (IPositionManager.sol:91-98). What the interface leaves out is the zero-share accrual case:_pendingFeesreturns zero management and performance shares whenevertotalFeeAssets >= totalAssets_(PositionManagerBase.sol:245-255), andconvertToSharesrounds down, so an accrual can mint nothing whileheldManagementFeesstill carries assets. That behavior is explained only in the internal comment block atPositionManagerBase.sol:232-244, which integrators reading the interface do not see.// src/manager/base/PositionManagerBase.sol:245-255 if (totalFeeAssets >= totalAssets_) { return ( totalAssets_, totalSupply_, currentDebt, 0, 0, advanceReference, advanceReference ? 0 : _storage.heldManagementFeeAssets ); }Recommendation
Document the zero-share outcome and the held-fee lifecycle on
IPositionManager.feeData()/pendingFees(): when the mint is skipped, thatlastFeeAccrualTimestampand the reference still move, and that a zero share result does not mean the held-fee accumulator is empty.Held management fee accumulator tracks charged assets, not minted shares, and this is undocumented
Context: PositionManagerAdmin.sol#L158-L196, PositionManagerBase.sol#L65-L114, IPositionManagerAdmin.sol#L100-L130
Both fee transitions accrue under the previous terms before writing new state:
setFeeDatacalls_accrueFees()atPositionManagerAdmin.sol:164before storing the newFeeData, andresetPerformanceReferencecalls it atPositionManagerAdmin.sol:187before moving the reference and zeroingheldManagementFeeAssets. That ordering is covered by the interface NatSpec atIPositionManagerAdmin.sol:101-103andIPositionManagerAdmin.sol:112-129.What the NatSpec does not state is that the accumulator is denominated in charged assets rather than minted shares.
PositionManagerBase.sol:229adds the whole interval'smanagementFeeAssetstoheldManagementFeeAssetswhile the reference is held, but the share conversion atPositionManagerBase.sol:260-267rounds down, so an interval can mint zero shares to the fee recipient and still add its asset amount to the accumulator. That amount is deducted from the next positive performance basis atPositionManagerBase.sol:223-224, and it is rescaled with share supply atLibStorage.sol:286-288, so it needs to stay distinct from fee-recipient entitlement and from the performance deduction itself. The remainder is bounded by rounding.// src/manager/base/PositionManagerBase.sol:219-229 // Performance fee on the levered-slice basis, net of the management fees charged since the // reference last advanced: the held accumulator plus the current interval's charge. heldManagementFees_ = _storage.heldManagementFeeAssets; totalFeeAssets = managementFeeAssets; if (fd.performanceFee > 0 && basis > managementFeeAssets + heldManagementFees_) { totalFeeAssets += (basis - managementFeeAssets - heldManagementFees_).mulDiv(fd.performanceFee, BPS); } // While the reference is held, the current interval's management fee joins the accumulator // so the next crystallization deducts it; on advance the pending deduction is consumed (or, // for any excess above the basis, forgiven) and the accumulator restarts. heldManagementFees_ = advanceReference ? 0 : heldManagementFees_ + managementFeeAssets;Recommendation
Document on
heldManagementFeeAssets(LibStorage.sol:97) and in thesetFeeData/resetPerformanceReferenceNatSpec that the accumulator records charged assets independently of whether the interval minted fee shares, and that a floored interval still reduces the next crystallization.Retargetter's flash-loan callback binds neither the payload nor the delivered principal
Context: Retargetter.sol#L468-L530
startSyncRetargettingchecks the module whitelist, stores the module inWINDOW_TSLOT/MODULE_TSLOTand the nominal loan amount inAMOUNT_TSLOT, then callsIFlashLoanModule.flashLoan(Retargetter.sol:536-551). Step authority for the whole window belongs to that module address.onFlashLoanauthenticates the caller and the nominal amount, then decodes the module-supplieddataand delegatecalls it through_multicall(Retargetter.sol:576-593). The payload is not bound to thedatapassed tostartSyncRetargetting, and the delivered principal is never checked againstAMOUNT_TSLOTbefore execution. A module can deliver zero or partial funds, run a differentrebalancepayload, borrow the missing amount through PositionManager, and still receive the approval for the nominal repayment atRetargetter.sol:592.// src/manager/rebalancer/Retargetter.sol:576-593 function onFlashLoan(uint256 amount, bytes calldata data) external { address module = MODULE_TSLOT.tLoadAddress(); if (module == address(0) || msg.sender != module || amount != AMOUNT_TSLOT.tLoadUint()) { revert LibRetargetterErrors.UnauthorizedFlashLoanCallback(); } MODULE_TSLOT.tStoreAddress(address(0)); // `data` is the abi.encode of the step calls built by startSyncRetargetting and forwarded // verbatim by the module; point a calldata array at it in place instead of copying it bytes[] calldata calls; assembly ("memory-safe") { let arrayOffset := add(data.offset, calldataload(data.offset)) calls.offset := add(arrayOffset, 0x20) calls.length := calldataload(arrayOffset) } _multicall(calls); // The module pulls its repayment through this allowance after the callback returns LibStorage.assetsStorage().debtAsset.safeApproveWithRetry(module, amount); }An arbitrary external caller cannot forge the callback:
startSyncRetargettingisonlyOwnerOrRebalancerand the module slot is single-shot. The in-repoMorphoFlashLoanAdaptertransfers the full principal to the initiator and forwards the payload verbatim (MorphoFlashLoanAdapter.sol:85-96), so this needs a different whitelisted module.Example
Assume USDC debt / wUSCC collateral (both 6 decimals), PositionManager NAV
totalAssetsof 1,000,000 USDC (1e12),maxRebalanceLoss == 50bps, and a collateral quote unchanged across the window.- Module opens a window for a nominal 1,000,000 USDC (1e12) loan and delivers zero, then supplies its own payload.
- Payload borrows through PositionManager, so a 12,000 USDC (1.2e10) module profit lands as an equal debt increase and an equal NAV loss.
- With
rebalanceCooldown == 0, one aggregate 12,000 USDC call reverts at 120 bps, while three 4,000 USDC (4e9) calls pass at 40, 40.2, and 40.3 bps against the shrinking NAV. - A nonzero cooldown blocks the per-call slicing but leaves the payload, delivered principal, and execution window unbound.
Recommendation
Document that the flash-loan module is trusted and outside the compromised-module threat model.
Standing BorrowOffers have no cap on liquidation bonus, seized equity, or close factor
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
m4rio
Context: MorphoBorrowPosition.sol#L255-L329, MorphoBorrowPosition.sol#L722-L756, LibBorrowOffers.sol#L287-L448, BorrowOffersRegistry.sol#L129-L156
proposeOfferis gated byOFFERS_REGISTRY.checkCanCreateOffer(MorphoBorrowPosition.sol:863), so anyPROPOSER(set by the registry owner viaBorrowOffersRegistry.setProposer) can create a standing offer that repays debt and transfers nearly all position collateral to whoever fills it. The position owner has no bypass and no veto; revocation sits with guardians and the registry owner.Both the proposal-time filter (
MorphoBorrowPosition.sol:898-908) and the per-fill gate check profitability against a minimum bonus floor plus a strict LTV decrease. Neither bounds the bonus from above, the seized equity, or the close factor. The consume walk is limited only by the position's remaining collateral and shares (LibBorrowOffers.sol:310-337).// src/libs/borrow/LibBorrowOffers.sol:436-444 uint256 seizedValue = fillCollateral.mulDiv(inp.price, ORACLE_PRICE_SCALE); uint256 repaidDebtValue = fillShares.toAssetsUp(inp.totalBorrowAssets, inp.totalBorrowShares); if (!isProfitableAboveBonusFloor(seizedValue, repaidDebtValue, inp.minOfferBonusBps)) return FillAction.Skip; uint256 remainingDebtValue = remainingPositionShares.toAssetsUp(inp.totalBorrowAssets, inp.totalBorrowShares); uint256 remainingCollateralValue = remainingPositionCollateral.mulDiv(inp.price, ORACLE_PRICE_SCALE); if (!strictlyLowersLtv(seizedValue, repaidDebtValue, remainingDebtValue, remainingCollateralValue)) { return FillAction.Stop; }A proposer can therefore price an offer close to full collateral value. Once the veto window (
activeAt) passes, any public liquidator can repay and fill it:test/borrow/MorphoDormantOfferActivation.t.solfills an offer sized at 90% of position shares and asserts the seizure exceeds 89% of position collateral. A former registry owner who retainsPROPOSERafter handoff has the same authority and can capture all but one raw unit of another manager's equity; that stale-handoff case changes the actor, not the loss ceiling, and is outside this rating.Recommendation
Cap the proposer-authorized liquidation bonus, seized equity, and close factor in the standing-offer fill path. If proposers are meant to be fully trusted administrators, document full liquidation as approved emergency authority instead.
Offer-walk Stop/Skip semantics and raw-unit rounding are not documented on the external liquidation surface
Context: LibBorrowOffers.sol#L140-L262, LibBorrowOffers.sol#L287-L408, MorphoBorrowPosition.sol#L255-L329
The offer walk converts between collateral units, debt shares, and oracle-scaled values with mixed rounding:
fillSharesrounds up (LibBorrowOffers.sol:391), the repaidShares-mode collateral cap and the position-clamp rescale round down (LibBorrowOffers.sol:381,LibBorrowOffers.sol:404), and_priceActionrounds collateral value down and debt value up (LibBorrowOffers.sol:436-441). One-atom boundaries therefore decide whether an offer yieldsConsume,Skip, orStop.Skipmoves to the next offer,Stopends the walk (LibBorrowOffers.sol:318-319), so the same caller-selected partial target can land on a different tail of the offer book and produce different final seized/repaid totals.These rules live only in internal
LibBorrowOffersnatspec. The external surface callers actually use,preLiquidateandpreviewConsume, does not state them: thepreLiquidatenatspec covers the proportional-path bonus math, the Morpho health adjustment, the callback, and the LTV dispatch (MorphoBorrowPosition.sol:260-334), but says nothing about per-offer rounding directions orStopversusSkip. Integrations and monitoring cannot interpret a partial offer liquidation from the public docs alone.// src/libs/borrow/LibBorrowOffers.sol:315-319 (FillAction action, uint256 fillCollateral, uint256 fillShares) = _computeFill(inp, walkOffer.remainingCollateral, walkOffer.remainingDebtShares, totalSeized, totalDebtShares); if (action == FillAction.Stop) break; if (action == FillAction.Skip) continue;Recommendation
Document the raw-unit rounding directions and the exact
StopandSkipconditions on the external liquidation surface (preLiquidate/previewConsumenatspec plus integrator docs), so callers interpret partial liquidation results consistently.Minimum tick is payable out of PositionManager while committed Request principal stays unused
Context: Retargetter.sol#L253-L285, RetargetterQuoter.sol#L117-L129
Retargetter.repay()sizes the transfer asowed - requestBalance(Retargetter.sol:314-315), so an operation that consumed an offer but never calledpullRequestFundsstill leaves the full principal sitting in the Request and only tops up the yield leg from PositionManager.RetargetterQuoter.paidDurationfloors at one tick (RetargetterQuoter.sol:188-189), so even same-block settlement owes a full tick of YT. The maker later redeems PT and YT for principal plus that tick, and PositionManager LPs eat the tick plus the borrow cost for capital that never left the Request.// src/manager/rebalancer/Retargetter.sol:312-318 owedAmount = _owed(operation_); address debtAsset = LibStorage.assetsStorage().debtAsset; uint256 requestBalance = debtAsset.balanceOf(request); uint256 shortfall = owedAmount > requestBalance ? owedAmount - requestBalance : 0; if (shortfall > 0) { debtAsset.safeApproveWithRetry(request, shortfall); IRequestInteractions(request).repay(shortfall);// src/manager/rebalancer/RetargetterQuoter.sol:187-190 if (tickDuration == 0 || tickThreshold >= tickDuration) revert LibRetargetterErrors.InvalidParameters(); uint256 paidTicks = (elapsed + tickDuration - tickThreshold) / tickDuration; if (paidTicks == 0) paidTicks = 1; duration = paidTicks * tickDuration;Example
Config:
horizon = 90 days,tickDuration = 1 days.- Consumer consumes a maker offer for
amount = 1,000,000.000000USDC principal andexpectedReturn = 12,000.000000USDC yield. The Request holds 1,000,000 USDC; the maker holds 1,000,000 PT and 12,000 YT. - Rebalancer skips
pullRequestFunds, so no wUSCC collateral is ever bought and the USDC stays put. - Rebalancer calls
repay()immediately.elapsed = 0givespaidTicks = 1,duration = 1 days, soowed = 1,000,000e6 + ceil(12,000e6 * 1 / 90) = 1,000,000e6 + 133,333,334. requestBalance = 1,000,000e6, soshortfall = 133.333334USDC, borrowed from PositionManager.- Maker burns PT + YT for 1,000,000.133334 USDC. PositionManager LPs are down 133.333334 USDC plus borrow interest.
Recommendation
Document that PositionManager LPs can fund the minimum tick while Request principal stays unused, or skip the tick floor when no principal was pulled.
- Consumer consumes a maker offer for
remediationDelta counts expected collateral yield twice
Context: RetargetterQuoter.sol#L78-L104, RetargetterQuoter.sol#L132-L155
ltvDownPrincipalsizescollateralToFreeQuoted = principal * (1 + Yr) / (1 + Yc), so the freed wUSCC already has the expected collateral yieldYcdiscounted out of it (RetargetterQuoter.sol:172-174).remediationDeltathen applies the raw price ratiorho = p1 / p0to the full repayment (RetargetterQuoter.sol:215-219). When the only price movement is the expected collateral yield,rho = 1 + Yc, the redemption proceeds equal the repayment and the true mismatch is zero, but the helper reportsrepayment * Yc.// src/manager/rebalancer/RetargetterQuoter.sol:172-174 collateralToFreeQuoted = principal.fullMulDivUp( WAD + _scaledRate(requestYieldRate, duration), WAD + _scaledRate(collateralYieldRate, duration) );// src/manager/rebalancer/RetargetterQuoter.sol:214-219 // The fixed bridge repayment the redemption proceeds are measured against uint256 repayment = principal.fullMulDiv(WAD + _scaledRate(requestYieldRate, duration), WAD); uint256 magnitude; if (priceDriftWad >= WAD) { // Surplus: proceeds exceed the repayment; rounds down so the fold never overshoots magnitude = repayment.fullMulDiv(priceDriftWad - WAD, WAD);Selector
0x114d0641has no consumer in this repo, and no state-changing use that could misallocate funds was identified. An off-chain caller sizing a remediation or showing a quote gets the wrong surplus.Example
requestYieldRate = collateralYieldRate = 5e16(5% per 365 days),duration = 30 days, soYr = Yc = 4109589041095890(0.41096%).principal = 1_000_000e6USDC.ltvDownPrincipalfreescollateralToFreeQuoted = 1_000_000e6(1,000,000 wUSCC quoted in USDC).- Repayment is
1_000_000e6 * (1 + Yr) = 1_004_109.589041e6USDC. - Price moves exactly by the collateral yield:
priceDriftWad = 1.00410958904109589e18. Proceeds are1_000_000e6 * (1 + Yc) = 1_004_109.589041e6USDC, so the true delta is 0. remediationDeltareturns+4_126.477763e6, i.e. a phantom 4,126.47 USDC surplus per 1,000,000 USDC of principal.
Recommendation
Document
remediationDeltaas an advisory raw-price quote, not an exact settlement value or a state-changing cap, and require callers to treat it that way.Rebalance cooldown is enforced per call, so a SYNC payload with two PositionManager rebalances always reverts
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
m4rio
Context: PositionManagerRebalancing.sol#L45-L119, Retargetter.sol#L468-L530
Retargetter.startSyncRetargettingruns its whole payload inside one flash-loan callback, so every inner call shares a block timestamp.PositionManagerRebalancing.rebalancechecks the cooldown once per call (PositionManagerRebalancing.sol:62-74) and writeslastRebalanceTimestamp = uint40(block.timestamp)on success (PositionManagerRebalancing.sol:106). Multiple operations inside onerebalance()call share one check; multiple calls each get their own.With
rebalanceCooldown > 0, the secondPositionManager.rebalance()in a SYNC payload sees zero elapsed time, reverts withRebalanceCooldownNotElapsed, and rolls back the flash-loan transaction. A payload with one rebalance succeeds. No supported production payload has been shown to need two.A prior successful rebalance also blocking the first call of a later SYNC is specified cooldown behavior, separate from this.
// src/manager/base/PositionManagerRebalancing.sol:59-74 // Enforce cooldown between consecutive rebalance calls // (block-scoped so the temporaries do not deepen the stack for the rest of the function) { uint40 cooldown = _storage.rebalanceConfig.rebalanceCooldown; uint40 lastRebalance = _storage.rebalanceConfig.lastRebalanceTimestamp; if (cooldown > 0 && lastRebalance > 0) { // Safe: block.timestamp fits in uint40 for ~35,000 years // Subtraction is safe because block.timestamp >= lastRebalance (time is monotonic). // Using `elapsed < cooldown` instead of `timestamp < lastRebalance + cooldown` // to avoid uint40 overflow when cooldown is large. // forge-lint: disable-next-line(unsafe-typecast) if (uint40(block.timestamp) - lastRebalance < cooldown) { revert LibManagerErrors.RebalanceCooldownNotElapsed(); } } }Recommendation
Scope the cooldown to the flash-loan callback rather than to each nested
PositionManager.rebalance()call, so a multi-call payload takes one check. If the per-call semantics stay, document that compatible legs must be packed into a singlerebalance()call and that routes needing an intervening Fund action have to run as separate transactions.Rebalance post-check reverts on modules the payload never touched
Context: Retargetter.sol#L392-L455
Retargetter.rebalance()snapshots every module LTV before calling the PositionManager and re-checks every module after, including modules absent from the payload (Retargetter.sol:500-507). A module ending abovetargetmust have strictly decreased. An untouched module sitting abovetargethasmoduleLtvAfter == moduleLtvsBefore[i], so it reverts withPositionAboveTarget, taking the valid legs on the other modules with it. Direct owner calls skip the block (Retargetter.sol:495); rebalancer calls and anything inside a flash-loan window do not.// src/manager/rebalancer/Retargetter.sol:495-508 if (msg.sender != owner()) { uint256 ltvAfter = _positionManagerLtv(positionManager); if (ltvAfter > target && ltvAfter >= ltvBefore) { revert LibRetargetterErrors.AboveTargetLtv(ltvAfter, ltvBefore, target); } for (uint256 i = 0; i < modulesLength; ++i) { address module = modules[i]; uint256 moduleLtvAfter = _moduleLtv(module); if (moduleLtvAfter == type(uint256).max) revert LibRetargetterErrors.BadDebtPosition(module); if (moduleLtvAfter > target && moduleLtvAfter >= moduleLtvsBefore[i]) { revert LibRetargetterErrors.PositionAboveTarget(module); } } }Near the target, floors can flatten a one-atom improvement into an equal value and trip the same guard. That is the same liveness boundary, not a loss path.
Example
- Target is 70%. Module A holds wUSCC worth 100,000 USDC against 71,000 USDC debt (71% LTV). Module B holds wUSCC worth 100,000 USDC against 75,000 USDC debt (75% LTV).
- The rebalancer submits a payload repaying 10,000 USDC on module B only, taking it to 65,000/100,000 = 65%.
- The post-check reads module A at 71% after and 71% before: above target, not strictly decreasing, so
PositionAboveTarget(A)fires. - The 10,000 USDC repayment on module B reverts with it. Aggregate LTV drops from 73% to 68%, and the call still fails.
Recommendation
Skip the per-module direction check for modules the payload does not touch, or require only that an untouched module does not increase.
pullRequestFunds(0) is an undocumented close of the Retargetter funding round
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
m4rio
Context: Retargetter.sol#L253-L262, LibStorage.sol#L201-L214
Retargetter.pullRequestFundscallscloseConsumptionatRetargetter.sol:292, before it resolves or transfersamount.closeConsumptionsetsconsumptionClosedand revokes every pending mint authorization on the Request (LibStorage.sol:211-219). Calling it withamount == 0moves no capital and still ends the funding round.After that call
consumeand nonzeroauthorizeMintingrevert withConsumptionWindowClosed; zero-amount revocations still pass. Signed offers stay stored but are unusable for the operation, and funding resumes only after the operation settles and restarts or is abandoned and cleared. The owner andREBALANCER_ROLEare authorized for this transition, but no separate entry point or documentation names it.// src/manager/rebalancer/Retargetter.sol:287-296 function pullRequestFunds(uint256 amount) external onlyOwnerOrRebalancer nonReentrant { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); // Pulling is the point of no return for the funding round: capital entry shuts and every // pending authorization is revoked, so funds being deployed can no longer be diluted operation_.closeConsumption(request); if (amount == FULL_BALANCE_SENTINEL) amount = LibStorage.assetsStorage().debtAsset.balanceOf(request); IRequestInteractions(request).pullFunds(amount, ""); emit RequestFundsPulled(request, amount); }// src/libs/manager/rebalancer/LibStorage.sol:211-219 function closeConsumption(RetargetterOperation storage self, address request) internal { self.consumptionClosed = true; EnumerableSetLib.AddressSet storage accounts = self.authorizedAccounts; for (uint256 remaining = accounts.length(); remaining > 0; --remaining) { address account = accounts.at(remaining - 1); IRequest(request).authorizeMinting(account, 0, 0); accounts.remove(account); } }Recommendation
Add an explicit
closeFundingRound()entry point and reservepullRequestFundsfor transfers. If the overload stays, documentpullRequestFunds(0)as the close transition, its authorized roles, that no value moves, and that existing Request funds remain pullable.Retargetter's one-operation-at-a-time guard is per instance, so two Retargetters can each admit the full principal cap
Context: Retargetter.sol#L803-L829, PositionManagerRebalancing.sol#L55-L64
_checkStartenforces one live operation per Retargetter using that instance's own storage, andmaxPrincipal(positionManager)(Retargetter.sol:688-712) derives the cap from the PositionManager's currentcollateralAmountQuoted()anddebtAmount(). Neither reads principal already admitted by a different Retargetter.If the owner grants
REBALANCER_ROLEon the same PositionManager to two Retargetters, both can pass_checkStartat the full cap before either operation moves PositionManager state, so combined admitted principal exceeds the intended single-operation capacity.// src/manager/rebalancer/Retargetter.sol:791-800 function _checkStart(RetargetterOperation storage operation_, address positionManager, address fund, uint256 amount) internal view { if (operation_.positionManager != address(0)) revert LibRetargetterErrors.OperationActive(); if (operation_.orderLive) revert LibRetargetterErrors.OrderActive(); _checkPair(positionManager); if (!LibStorage.whitelistsStorage().funds[fund]) revert LibRetargetterErrors.FundNotWhitelisted(); if (amount > maxPrincipal(positionManager)) revert LibRetargetterErrors.PrincipalCapExceeded(); }Recommendation
Document that at most one Retargetter may hold
REBALANCER_ROLEon a given PositionManager, and that the exclusivity guarantee is per Retargetter instance rather than per PositionManager.Splitting an exit across many transactions rounds heldManagementFeeAssets down repeatedly
Context: LibStorage.sol#L174-L196
rebaseSnapshotrescales the pending management-fee deduction bynewSupply / prevSupplyon every supply change, flooring each time (LibStorage.sol:288). Splitting one exit into N exits applies N floors instead of one, so the surviving deduction is up to N-1 base units (1e-6 USDC) below the aggregate result._pendingFeessubtracts that deduction from the performance basis (PositionManagerBase.sol:223-224), so a smaller deduction means a larger performance-fee mint on the next crystallization.// src/libs/manager/LibStorage.sol:285-290 if (newSupply != prevSupply && prevCollat > 0) { uint256 heldManagementFeeAssets = self.heldManagementFeeAssets; if (heldManagementFeeAssets > 0) { self.heldManagementFeeAssets = heldManagementFeeAssets.mulDiv(newSupply, prevSupply); } }Example
heldManagementFeeAssets = 5_000_123456(5,000.123456 USDC), share supply10_000e18.- One exit burning
100e18shares:floor(5_000_123456 * 9_900e18 / 10_000e18) = 4_950_122_221(4,950.122221 USDC). - Instead, 100 exits of
1e18shares each, flooring after each:4_950_122_156(4,950.122156 USDC). The next positive basis charges the performance fee on that extra 0.000065 USDC.
Recommendation
Document the loss, as it's dust level.
Partial fills can leave all offer slots live but unusable
Context: LibBorrowOffers.sol#L91-L142, LibBorrowOffers.sol#L160-L188, LibBorrowOffers.sol#L273-L368
LibBorrowOffers.consume()clears an offer only when its remaining collateral or remaining debt shares reach zero (LibBorrowOffers.sol:205). A partial fill can leave both values nonzero even when the remaining ratio no longer passes the profitability, bonus-floor or de-risking checks in_priceAction(LibBorrowOffers.sol:436-446).// src/libs/borrow/LibBorrowOffers.sol:202-216 for (uint256 i; i < walkLength; ++i) { WalkOffer memory walkOffer = offers[i]; if (walkOffer.filledCollateral == 0) continue; // untouched by the walk bool exhausted = (walkOffer.remainingCollateral == 0 || walkOffer.remainingDebtShares == 0); if (exhausted) { clearBits |= uint256(1) << walkOffer.id; delete s.slab[walkOffer.id]; } else { Offer storage offer = s.slab[walkOffer.id]; offer.remainingCollateral = walkOffer.remainingCollateral; offer.remainingDebtShares = walkOffer.remainingDebtShares; } emit IBorrowOffers.OfferConsumed(walkOffer.id, walkOffer.filledCollateral, walkOffer.filledShares, exhausted); }The unusable offer stays in
liveBits._alloc()frees only expired slots, so a book holdingMAX_OFFERS(32) such offers rejects new proposals withTooManyOffers, andpreLiquidatereverts withNoConsumableOffer(MorphoBorrowPosition.sol:429) when the walk finds no valid fill.// src/libs/borrow/LibBorrowOffers.sol:149-164 function _alloc(BorrowOffersStorage storage s) private returns (uint8 id) { uint256 liveBits = s.liveBits; uint256 scanBits = liveBits; while (scanBits != 0) { uint256 scanId = LibBit.ffs(scanBits); scanBits &= scanBits - 1; // clear the lowest set bit if (block.timestamp >= s.slab[scanId].expiresAt) { liveBits &= ~(uint256(1) << scanId); delete s.slab[scanId]; } } uint256 freeBits = ~liveBits & ((uint256(1) << MAX_OFFERS) - 1); if (freeBits == 0) revert LibBorrowErrors.TooManyOffers(); id = uint8(LibBit.ffs(freeBits)); s.liveBits = uint32(liveBits | (uint256(1) << id)); }Example
Debt is USDC and collateral wUSCC, both 6 decimals; debt shares are quoted at Morpho's
VIRTUAL_SHARESratio of1e6shares per asset unit.- Each of the 32 offers holds 800,000 wUSCC (
8e11) of collateral against 600,000 USDC (6e11) of debt, i.e.6e17debt shares. - A liquidator fills
8e11 - 3collateral base units (800,000 wUSCC less 3 units of dust). fillShares = ceil((8e11 - 3) * 6e17 / 8e11) = 6e17 - 2,250,000, leaving remaining collateral3(0.000003 wUSCC) and remaining debt shares2,250,000(about 0.00000225 USDC).- Both remaining values are nonzero, so the offer stays in
liveBits, whileseizedValueon 3 collateral units rounds down andrepaidDebtValuerounds up, so the profitability and bonus-floor checks fail. - Repeat across all 32 slots. The book stays full holding 96 base units (0.000096 wUSCC) of collateral in total, and new offers covering the remaining 19,200,000 USDC of pre-liquidation capacity are blocked until a guardian revokes them or they expire.
Recommendation
Clear an offer when its remaining amounts cannot produce a nonzero fill that passes the offer checks.
- Each of the 32 offers holds 800,000 wUSCC (
setConfig reprices the repayment of an already-started Retargetter operation
Context: Retargetter.sol#L148-L190, Retargetter.sol#L493-L498, Retargetter.sol#L702-L718
startRetargetting()snapshots only the per-operation yield cap into operation storage (Retargetter.sol:227-231).tickDuration,tickThreshold, andhorizonstay in config storage, and_owed()reads them live atRetargetter.sol:834-843. The owner can callsetConfig()after lenders already hold PT and YT, and the running operation settles on the new terms.// src/manager/rebalancer/Retargetter.sol:832-844 function _owed(RetargetterOperation storage operation_) internal view returns (uint256) { (uint128 ptSupply, uint128 ytSupply) = ITokenController(operation_.request).totalSupplies(); RetargetterConfig storage config_ = LibStorage.configStorage(); return IRetargetterQuoter(_QUOTER) .repaymentOwed( ptSupply, ytSupply, block.timestamp - operation_.startedAt, config_.tickDuration, config_.tickThreshold, config_.horizon ); }RetargetterQuoter.repaymentOwedcharges at least one full tick (RetargetterQuoter.sol:188-205), so raisingtickDurationraises the yield leg of the repayment for an operation that is already funded.Example
PT and YT are denominated in the debt asset, USDC (6 decimals). Assume
horizon90 days (MIN_HORIZON),tickDuration1 day,tickThreshold0.- Operation has 5,000,000 USDC of PT (5e12) and 150,000 USDC of YT (1.5e11).
- Owed at start:
paidDurationcharges one tick = 1 day, owed = 5e12 + ceil(1.5e11 * 86,400 / 7,776,000) = 5e12 + 1,666,666,667 = 5,001,666.666667 USDC. - Owner sets
tickDurationto 30 days (MAX_TICK_DURATION) before repayment. - Same supplies now owe 5e12 + 1.5e11 * 2,592,000 / 7,776,000 = 5e12 + 5e10 = 5,050,000 USDC.
The repricing costs the borrower 48,333.33 USDC on a repayment that was funded on the 1-day tick.
Recommendation
The implementation
@devatRetargetter.sol:600-601states config changes apply to an in-flight operation, butIRetargetter.setConfig(IRetargetter.sol:298-300) does not. Carry that note onto the interface so lenders and integrations reading the ABI docs see thattickDuration,tickThreshold, andhorizonare live inputs to repayment.CONSUMER_ROLE can authorize itself to mint YT up to the yield cap
Context: Retargetter.sol#L191-L230, LibStorage.sol#L149-L175, Request.sol#L328-L339
Retargetter.authorizeMinting()atRetargetter.sol:264is gated byonlyOwnerOrRoles(CONSUMER_ROLE)and lets the caller pick thetorecipient freely, including its own address. The only limits on the amounts are the yield ratio gate and the principal cap inLibStorage.checkOffer()atLibStorage.sol:170-176.The recipient then calls
Request.mint()(Request.sol:378), transfersptMintAuthdebt-asset units in, and receives the authorized PT and YT. The Retargetter trust description does not state thatCONSUMER_ROLEholds this authority.// src/manager/rebalancer/Retargetter.sol:264-283 function authorizeMinting(address to, uint128 ptAmount, uint128 ytAmount) external onlyOwnerOrRoles(CONSUMER_ROLE) nonReentrant { RetargetterOperation storage operation_ = LibStorage.operationStorage(); address request = operation_.checkRequest(); // Replace semantics: drop the account from the set so the principal gate sizes the new // amounts as fresh capital; a full revocation (both amounts zero) skips every gate, since // it only shrinks exposure and must stay available once the window has closed EnumerableSetLib.AddressSet storage accounts = operation_.authorizedAccounts; accounts.remove(to); if (ptAmount != 0 || ytAmount != 0) { operation_.checkOffer(request, ptAmount, ytAmount, ptAmount, maxPrincipal(operation_.positionManager)); // The capacity bound keeps every loop over the set within gas reach; see the constant accounts.add(to, MAX_AUTHORIZED_ACCOUNTS); } IRequest(request).authorizeMinting(to, ptAmount, ytAmount); emit MintingAuthorized(request, to, ptAmount, ytAmount); }// src/libs/manager/rebalancer/LibStorage.sol:170-177 if (expectedReturn * BPS > amount * self.operationMaxYieldBps) { revert LibRetargetterErrors.YieldTooHigh(); } (uint128 ptSupply,) = ITokenController(request).totalSupplies(); if (ptSupply + pruneNullAuthorizations(self, request) + ptAmount > principalCap) { revert LibRetargetterErrors.PrincipalCapExceeded(); } checkConsumptionWindow(self);Example
PT and YT are denominated in the debt asset, USDC at 6 decimals.
operationMaxYieldBps = 1000(10%), live principal cap has 1,000,000 USDC (1e12) of headroom.- Consumer calls
authorizeMinting(consumer, 1e12, 1e11), i.e. 1,000,000 USDC of PT and 100,000 USDC of YT. - Yield gate:
1e11 * 10000 = 1e15is not> 1e12 * 1000 = 1e15, so it passes at exactly the cap. - Principal gate:
ptSupply + authorizations + 1e12 <= cap, passes. - Consumer calls
Request.mint(), transfers 1,000,000 USDC (1e12), receives 1,000,000 USDC of PT and a 100,000 USDC yield entitlement in YT, directed to itself.
Recommendation
Document that
CONSUMER_ROLEcan authorize itself or another recipient to mint YT up to the operation's yield cap.Offer proposers cannot cancel their own timelocked offers
Context: MorphoBorrowPosition.sol#L715-L772, IBorrowOffers.sol#L79-L94
proposeOfferrecordsmsg.senderas the proposer and setsactiveAt = block.timestamp + timelock(MorphoBorrowPosition.sol:862-878).revokeOffersgates oncheckCanRevokeOffer, which requiresGUARDIAN_ROLEor the registry owner (MorphoBorrowPosition.sol:916-917,BorrowOffersRegistry.sol:174-176). The docstring atMorphoBorrowPosition.sol:913-915states the omission is deliberate.A proposer that submits a mistaken offer has to ask a guardian or the registry owner to pull it. If neither responds before
activeAt, a permissionless liquidator can consume the offer on its submitted terms.// src/borrow/MorphoBorrowPosition.sol:862-864 function proposeOffer(uint128 collateral, uint128 debtShares, uint40 expiresAt) external override returns (uint8 id) { OFFERS_REGISTRY.checkCanCreateOffer(msg.sender); if (collateral == 0 || debtShares == 0) revert LibBorrowErrors.OfferAmountZero();// src/borrow/MorphoBorrowPosition.sol:916-917 function revokeOffers(uint8[] calldata ids) external override { OFFERS_REGISTRY.checkCanRevokeOffer(msg.sender);Recommendation
Let the recorded proposer revoke its own offer before
activeAt. Keep guardian and registry-owner revocation available at all times.PositionManager exposes no getter for the bad-debt exclusion flag
Context: LibView.sol#L55-L83, PositionManager.sol#L139-L147, IPositionManager.sol#L52-L60
LibView.totalAssets()skips any borrow module whose debt exceeds its quoted collateral, so that module contributes nothing toamount,totalDebt, ortotalCollateral, and setshasBadDebt(LibView.sol:90-96).PositionManager.totalAssets()dropshasBadDebt(PositionManager.sol:145-147), and no public view returns it. Monitoring has to query every borrow module and re-implement thecollateral >= debtcomparison to tell whether the reported NAV covers all modules or only the solvent ones.// src/libs/manager/LibView.sol:85-98 address[] memory modules = ps.borrowModules.values(); uint256 modulesLength = modules.length; for (uint256 i = 0; i < modulesLength; ++i) { uint256 collateral = IBorrowPosition(modules[i]).totalCollateralQuoted(); uint256 debt = IBorrowPosition(modules[i]).totalBorrowed(); if (collateral >= debt) { amount += collateral - debt; totalDebt += debt; totalCollateral += collateral; } else { hasBadDebt = true; } } }// src/manager/PositionManager.sol:144-147 /// @inheritdoc IPositionManager function totalAssets() public view nonReadReentrant returns (uint256 amount) { (amount,,,) = LibStorage.positionManagerStorage().totalAssets(); }Recommendation
Add a public
hasBadDebt()view returning the flag from the sameLibView.totalAssets()call, and document onIPositionManager.totalAssets()that the value excludes underwater modules when it is true.Retargetter ignores the Fund state returned by cancelOrder
Description
cancelOrdercallsIFund.cancel(order)atRetargetter.sol:409but ignores the returned Fund state. The Retargetter then sets its localorderLiveflag to false and clears its stored order.Current Fund implementations return
EMPTYor revert, so they remain consistent with the Retargetter. However, a future admitted Fund could return another state without reverting while its order remains active. The Retargetter would then forget an order that the Fund still processes.Proof of Concept
Source:
src/manager/rebalancer/Retargetter.sol:409IFund(fund).cancel(order_);A non-conforming future Fund could behave as follows:
Future Fund.cancel(order): returned state = PROCESSING Fund order = still active Retargetter: orderLive = false stored order = clearedRecommendation
Require
IFund.cancel(order)to returnState.EMPTYbefore clearing the Retargetter’s local order state.Timelock changes do not retime existing offers
Context: BorrowOffersRegistry.sol#L189-L209, BorrowOffersRegistry.sol#L251-L270, MorphoBorrowPosition.sol#L862-L878, LibBorrowOffersConstants.sol#L36-L42
setOfferTimelockschedules a new timelock using the collateral's current effective timelock.offerConfig()returns the pending value once its deadline is reached, even if the storage promotion has not yet been written.proposeOffersnapshots that effective value intoactiveAtwhen the offer is created.For example, if the current timelock is 15 minutes and governance schedules an increase to 7 days, an offer proposed before the scheduled change becomes effective receives the 15-minute activation delay. After the change takes effect, new offers use 7 days, but the earlier offer keeps its original
activeAtand can remain valid for up to the configured 365-day lifespan.// src/borrow/BorrowOffersRegistry.sol:199-209uint40 currentTimelock = _floorTimelock(_promoteTimelock(config));uint40 effectiveAt = uint40(block.timestamp + currentTimelock);config.pendingTimelock = timelock;config.pendingTimelockAt = effectiveAt;// src/borrow/MorphoBorrowPosition.sol:862-878(uint40 timelock, uint16 minOfferBonusBps) = _offerConfig();uint40 activeAt = uint40(block.timestamp + timelock);...id = LibBorrowOffers.borrowOffersStorage().insert(msg.sender, activeAt, expiresAt, collateral, debtShares);Recommendation
Document that timelock changes are grandfathered for existing offers.
MidasFund accepts arbitrarily old oracle rounds
Context: MidasFund.sol#L668-L680, MidasFund.sol#L886-L928
_getOraclePrice()validates that the answer is positive, thatupdatedAtis nonzero, and thatansweredInRound >= roundId. It does not check how much time has elapsed sinceupdatedAt:function _getOraclePrice() internal view returns (uint256) { AggregatorV3Interface _oracle = AggregatorV3Interface(_midasFundStorage().oracle); (uint80 _roundId, int256 _answer,, uint256 _updatedAt, uint80 _answeredInRound) = _oracle.latestRoundData(); if (_answer <= 0) revert LibFundsErrors.ChainlinkInvalidAnswer(); if (_updatedAt == 0) revert LibFundsErrors.ChainlinkIncompleteRound(); if (_answeredInRound < _roundId) revert LibFundsErrors.ChainlinkStaleRound(); return _answer.toUint256();}A completed round remains valid indefinitely even if the feed has stopped publishing. The price is used by
totalAssets()and by the create-time sanity check for depositorder.output. A stale-high mToken price lowers the expected token output, allowing a lower minimum output to pass the deviation check; it also overstates the wrapper-wide AUM returned bytotalAssets().The actual deposit mint amount is still determined when the Midas administrator approves the request, and governance can replace the feed through
setOracle(). No direct attacker-controlled loss from a currently abandoned production feed has been demonstrated, so this is configuration and monitoring hardening.Example
Assume the oracle's last completed round reports an mToken price of $1.00 and has not updated for one year. The current economic value has fallen to $0.80. All existing checks pass because the old answer is positive,
updatedAtis nonzero, and the round is complete.For a 100,000 USDC deposit, the stale price produces an expected output of 100,000 mTokens, so the 10% deviation check accepts an
order.outputof 90,000. At the current $0.80 value, the expected output would be 125,000 mTokens and the equivalent lower bound would be 112,500.totalAssets()likewise continues valuing every wrapper share at $1.00.Recommendation
Document the behavior and make sure this is monitored correctly.