Guardian's review of Morpho Integration for Tenor, published May 2026. The report records 77 findings across 2 review rounds, including 1 high and 25 medium.
- Published
- Review window
- April 20 to May 22, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Base
- Sector
- Lending
- 0 Critical
- 1 High
- 25 Medium
- 22 Low
- 29 Informational
Scope
39 files in scope · 1,751 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/RenewalIntentRatifier.sol | 217 | 257 |
src/RenewalIntentRegistry.sol | 28 | 45 |
src/RenewalIntentTakeOnBehalf.sol | 33 | 52 |
src/bundler/MidnightAuthorizationAdapter.sol | 19 | 50 |
src/bundler/RenewalIntentTakeOnBehalfAdapterBase.sol | 15 | 24 |
src/bundler/TakeRouterAdapterBase.sol | 48 | 70 |
src/bundler/TenorAdapter.sol | 14 | 20 |
src/callbacks/MorphoV1ToV2BorrowCallback.sol | 66 | 87 |
src/callbacks/MorphoV1ToV2LendCallback.sol | 30 | 48 |
src/callbacks/MorphoV2SupplyCollateralCallback.sol | 66 | 96 |
src/callbacks/MorphoV2SupplyVaultSharesCallback.sol | 39 | 62 |
src/callbacks/MorphoV2ToV1BorrowCallback.sol | 63 | 92 |
src/callbacks/MorphoV2ToV1LendCallback.sol | 34 | 56 |
src/callbacks/MorphoV2ToV2BorrowRepay.sol | 42 | 66 |
src/callbacks/MorphoV2ToV2LendWithdrawable.sol | 35 | 58 |
src/callbacks/MorphoV2WithdrawVaultSharesCallback.sol | 30 | 52 |
src/factories/DelayedLiquidationGateFactory.sol | 23 | 43 |
src/factories/MidnightAllowlistGateFactory.sol | 11 | 22 |
src/factories/OracleWithValidationCheckFactory.sol | 28 | 45 |
src/factories/VaultV2AllowlistGateFactory.sol | 11 | 21 |
src/gates/DelayedLiquidationGate.sol | 86 | 120 |
src/gates/MidnightAllowlistGate.sol | 30 | 51 |
src/gates/PauseTakeGate.sol | 32 | 66 |
src/gates/VaultV2AllowlistGate.sol | 49 | 77 |
src/libraries/CallbackLib.sol | 63 | 102 |
src/libraries/CollateralTransferLib.sol | 35 | 52 |
src/libraries/Constants.sol | 3 | 9 |
src/libraries/KeyLib.sol | 13 | 22 |
src/libraries/PriceLib.sol | 24 | 60 |
src/libraries/RatePointLib.sol | 15 | 28 |
src/libraries/ToIdLib.sol | 21 | 33 |
src/oracles/OracleWithValidationCheck.sol | 49 | 91 |
src/periphery/MidnightVaultExecutor.sol | 81 | 133 |
src/periphery/TakeRouter.sol | 219 | 297 |
src/periphery/libraries/MidnightLib.sol | 36 | 68 |
src/periphery/libraries/RouterLib.sol | 30 | 71 |
src/policies/FourWeekCadence.sol | 7 | 14 |
src/policies/PausableStaticRatePolicy.sol | 33 | 58 |
src/policies/StaticRatePolicy.sol | 73 | 105 |
Findings 77
Main Review
63 findings · April 20 to May 4, 2026-
H-01 High Overdue V2-V2 borrow renewals bypass max rate Logical Error Resolved
Description
BaseRenewalRatifierintentionally allows V2-source renewals after the source obligation has matured.BaseRenewalRatifier._ratifyWindow()only rejects executions that are too early, so once the clock passessourceMaturitythe renewal remains permanently executable. That behavior is documented and tested. The problem is that the downstream rate check does not switch to a post-maturity duration model when that overdue state is reached.For V2-to-V2 renewals,
BaseRenewalRatifier._computeDuration()always returnstargetMaturity - sourceMaturity. That is correct only while the source obligation is still alive. After maturity, the new debt does not begin at the expiredsourceMaturity; it begins when the keeper executes the renewal. The economically correct duration for the borrow-side rate check is thereforetargetMaturity - block.timestamp, or equivalentlytargetMaturity - max(block.timestamp, sourceMaturity).That distinction matters because
PriceLib.computePrice()is inversely proportional to duration. A longer duration produces a lower accepted price. On the borrower path,PriceLib.satisfiesRateLimit()treats a lower accepted price as a looser rate ceiling because the borrower only needs(assets - fee) * WADto stay aboveunits * price. In other words, once the ratifier uses an overdue-inclusive duration, it accepts prices that imply a materially higher realized rate over the actual remaining term of the renewed debt.The effect scales directly with overdue time. If a borrower set
limitRatePerSecondto cap a renewal at rater, the post-maturity V2-to-V2 check accepts the price for durationT + D, whereDis time overdue andTis the actual time from execution until the target maturity. The debt that is actually created lasts onlyT, so the same accepted discount corresponds to an effective ceiling ofr * (T + D) / T. A renewal that is overdue by sixty days and rolls into a new thirty-day target therefore weakens a ten percent ceiling into roughly a thirty percent realized ceiling.The ratifier explicitly accepts the overdue state, then computes borrower protection from the wrong time origin. The issue is reachable on live accounting because the source debt is already matured, the target maturity must still satisfy the user's
minDurationandmaxDurationrelative to the current timestamp, and the callback opens fresh target debt immediately after ratification.The lender side does not have the same risk direction. Reusing
targetMaturity - sourceMaturityon a lend renewal makes the floor stricter, not looser. The unsafe manifestation is specifically the V2-to-V2 borrow path because borrower ceilings are enforced with the seller-side inequality.Recommendation
If overdue rolling is not intended, close the V2 renewal window at
sourceMaturityand revert once the source obligation is overdue. That is the simplest fix because it restores the original interpretation ofsourceMaturityas the start of the renewed term.If overdue rolling is intended, keep the window semantics explicit but compute duration from the actual renewal start time:
function _computeDuration(address callback, uint256 sourceMaturity, uint256 targetMaturity) internal view returns (uint256) { if (callback == BORROW_V2_TO_V2_REPAY_CALLBACK || callback == LEND_V2_TO_V2_WITHDRAWABLE_CALLBACK) { uint256 renewalStart = block.timestamp > sourceMaturity ? block.timestamp : sourceMaturity; return targetMaturity - renewalStart; } ... } -
M-01 Medium Executor lets callers sweep shared balance Unexpected Behavior Acknowledged
Description
repayAndWithdrawCollateralandliquidateAndRedeemboth treat the executor's entireloanTokenbalance as if it belonged to the current caller. InrepayAndWithdrawCollateral, the function callsMidnight.repay, then transfersIERC20(obligation.loanToken).balanceOf(address(this))toonBehalf. It never tracks how much of that balance came from the current repayment. The function also acceptsrepayUnits == 0andsharesToWithdraw == 0, so any account can call it withonBehalf = msg.senderand withdraw every loan token already sitting on the executor.liquidateAndRedeemhas the same accounting problem from the funding side. It grants Midnight a max allowance and letsMidnight.liquidatepullrepaidUnitsfrom whatever balance the executor already holds. The integration tests model an EOA liquidator by pre-funding the executor before callingliquidateAndRedeem(test/integration/MidnightVaultExecutorIntegration.t.sol:823-832). When Midnight computesactualRepaidUnitsbelow the prefunded amount, the helper never refunds the surplus. That residue stays on the public executor and becomes claimable by the next caller throughrepayAndWithdrawCollateral, or usable as free repayment capital in a later liquidation. This directly contradicts the documentedEXEC-3invariant inPROPERTIES.md:316.Consequently any loan tokens stranded on the executor are not isolated to the account that supplied them. They are globally spendable. This is a direct loss of funds for liquidators or users who overfund the executor and the contract already acknowledges one source of residual dust in the mint path. The bug does not directly drain the normal protocol fee sinks: Tenor callback fees are sent to the configured
feeRecipientand Midnight trading fees accrue inside Midnight asclaimableTradingFee. The practical theft target is loan-token residue that actually lands on the executor, including unused liquidation prefunds, accidental transfers and executor-held dust.Recommendation
Stop using the executor's aggregate token balance as the payment and refund source. Snapshot the loan-token balance at function entry and only refund the positive delta created by the current call. Reject
repayAndWithdrawCollateralwhen bothrepayUnitsandsharesToWithdraware zero. ForliquidateAndRedeem, either require exact callback-based funding, or refundpostBalance - preBalancetomsg.senderand revert if the executor starts with a nonzero loan-token balance. If unexpected balances must remain recoverable, add a dedicated privileged rescue mechanism instead of a public sweep. -
M-02 Medium Primary-based deviation can erase buffer Logical Error Acknowledged
Description
price()scalesMAX_ORACLE_DEVIATIONbyprimaryPrice, then returns that sameprimaryPriceif the check passes. When the primary oracle is the high side, this acceptsprimaryPrice <= validationPrice / (1 - d)rather thanprimaryPrice <= validationPrice * (1 + d). Consequently a configured 5% threshold actually allows about 5.263% overpricing against the validation oracle.This matters because the README recommends sizing
MAX_ORACLE_DEVIATIONbelow(1 - Liquidation LTV)for lending markets. With a 95% LLTV market and a 5% threshold, this wrapper can still return a price high enough to let the protocol lend 100% of the validation-based collateral value. Even at 94.5% LLTV, the remaining buffer is only about 0.53%. A borrower who can push the primary oracle up within this accepted band can open positions that are effectively at or near full LTV on the validation price, so negligible market movement or routine interest accrual can leave bad debt.Recommendation
Measure the allowed deviation from
validationPrice, or more conservatively frommin(primaryPrice, validationPrice), instead ofprimaryPrice. If the current formula must remain, do not sizeMAX_ORACLE_DEVIATIONdirectly from(1 - LLTV). Document and enforce the stricter boundMAX_ORACLE_DEVIATION <= (1 - LLTV) / (2 - LLTV)for markets that rely on this wrapper to preserve liquidation margin. -
M-03 Medium V2-to-V2 lend can buy bad credit Unexpected Behavior Acknowledged
Description
MorphoV2ToV2LendWithdrawable.onBuyonly checks that the source and target obligations use the same loan token before withdrawing the lender's source credit. It does not prove that the target fill created fresh borrower debt backed by currently healthy collateral.That matters when the seller already has credit on the target obligation.
Midnight.take()updates positions before the callback runs. For a sell offer, it first decreases the seller's existing credit and only creates seller debt for any excess units:uint256 sellerCreditDecrease = UtilsLib.min(units, sellerPos.credit); uint256 sellerDebtIncrease = units - sellerCreditDecrease;If
sellerDebtIncrease == 0, the seller exits an existing lending position instead of opening a new borrow. Midnight's finalisLiquidatablecheck then returns immediately because the seller has no debt, so no collateral oracle is queried. Consequently, a lender's renewal can spend withdrawable source credit to buy target credit that is already backed by stale, worthless, or otherwise bad collateral. This can happen after the lender has configured the target market and authorized the callback, because any keeper can execute a matching renewal while the callback only enforces loan-token equality and source-credit availability.Recommendation
Require explicit opt-in before a V2-to-V2 lend renewal buys existing target credit. The safer default is to reject fills where the target seller has enough credit to avoid a debt increase, or to require the router or ratifier to verify a fresh target health/oracle check for the underlying target obligation before the source withdrawal is allowed.
If buying existing target credit is intended, expose it as a separate operation with distinct user consent and event semantics. Do not treat it as the same renewal as creating new target lending exposure against fresh borrower debt.
-
M-04 Medium Callback spends shared loan balance Unexpected Behavior Acknowledged
Description
onSellassumes the callback's currentloanTokenbalance came from the current Midnight fill. The function never checks thatreceiverIfMakerIsSellersentsellerAssetsto the callback, and it never snapshots the callback's balance before usingtotalDeposit.If the callback already holds
loanToken, a seller can sign a sell offer that uses this callback but routesreceiverIfMakerIsSellerto another address. Midnight will send the borrowedsellerAssetsto that receiver, thenonSellwill pull onlyamountFromSellerfrom the seller. The laterdeposit(totalDeposit, address(this))can consume the pre-existing callback balance for the missingsellerAssets, mint vault shares, and supply those shares as the seller's collateral.Consequently any loan tokens accidentally sent to this public callback, or otherwise stranded there, are not isolated. A later seller can convert that shared balance into their own vault-share collateral and unwind the position to recover the value.
Recommendation
Do not use the callback's aggregate token balance as implicit funding. At minimum, snapshot
IERC20(loanToken).balanceOf(address(this))at entry and require that the balance equalssellerAssetsbefore any seller top-up is pulled. After pullingamountFromSeller, require that the balance equalstotalDeposit. This makes wrong receiver configuration and pre-existing balances fail before the vault deposit. If stranded balances need recovery, add an explicit rescue function instead of letting future fills consume them. -
M-05 Medium Ratifiers miss seller receiver Unexpected Behavior Acknowledged
Description
RenewalIntentTakeOnBehalf.takeforwards a caller-suppliedreceiverintoMORPHO_MIDNIGHT.take, but it does not pass that receiver to the user-side ratifier. The ratifier only seescaller,takerCallback,intent,offer,takerCallbackDataandratifierData:IIntentRatifier(intent.ratifier) .onIntentRatify(msg.sender, takerCallback, intent, offerData.offer, takerCallbackData, ratifierData); MORPHO_MIDNIGHT.take( takeUnits, intent.user, takerCallback, takerCallbackData, receiver, offerData.offer, offerData.ratifierData, offerData.root, offerData.proof );This is a dangerous blind spot for renewal routes where
intent.useris the seller side of the Midnight trade. In those routes, Midnight sendssellerAssetstoreceiverIfTakerIsSellerbefore executing the seller callback. The in-repo seller-side renewal callbacks explicitly rely on that receiver being the callback contract so the callback can spend the fresh fill proceeds. For example,MorphoV2ToV2BorrowRepaystates that the offer receiver must be the callback, then transfersfee, approvesrepayBudget, repays the source obligation and moves collateral without proving that the current fill actually funded the callback.An arbitrary keeper can choose a different
receiverthat the ratifier cannot inspect. If the callback already holds enough loan-token balance, the fill can still complete: the keeper diverts the currentsellerAssetsto the chosen receiver, while the callback spends its old balance to pay the fee and repay or deposit on behalf of the user. The old balance can be accidental token residue, or it can be created by the callbacks themselves whenfeeRecipient == address(this), because the fee transfer becomes a no-op and onlysellerAssets - feeis spent afterward. The same aggregate-balance pattern appears insrc/callbacks/MorphoV1ToV2BorrowCallback.solandsrc/callbacks/MorphoV2ToV1LendCallback.sol.The root cause is that the user-side ratifier is supposed to police renewal shape, but one of the most important value-routing fields is omitted from the ratification payload.
Recommendation
Include
receiverinIIntentRatifier.onIntentRatifyand require the relevant ratifiers to validate it for each renewal route. Seller-side renewal routes should reject any receiver other than the expected callback contract.The callbacks should also enforce fresh funding instead of relying on their aggregate token balance. Consider asserting that the callback's loan-token balance increased by exactly the expected current-fill amount before spending, and that no unexpected loan-token balance remains afterward. Also reject
feeRecipient == address(this)or add an explicit controlled sweep path for accidental balances. -
M-06 Medium TakeRouter breaks caller-sensitive policies Logical Error Resolved
Description
The renewal flow is documented and typed so ratifiers can evaluate the original orchestrator caller, not just the user being renewed.
IIntentRatifier.onIntentRatifyexplicitly defines itscallerargument as the address that called the orchestrator boundary, andRenewalIntentRatifier._ratifyRateforwards that value intoIInterestRatePolicy.getRate(...)so policies can implement caller-sensitive authorization or pricing.Vulnerability: the
ORCHESTRATOR_TAKEbranch inTakeRouterbreaks that contract. Instead of preserving the external caller it callsRenewalIntentTakeOnBehalf.take, andRenewalIntentTakeOnBehalfthen forwards its ownmsg.senderto the ratifier. For routed renewals, the ratifier therefore sees the router or adapter contract address instead of the real keeper / bundler initiator.Impact: any deployment that relies on caller-sensitive
interestRatePolicylogic can have that policy silently bypassed or unexpectedly DOSed when execution is routed throughTakeRouter. A policy that is supposed to whitelist specific keepers, deny specific operators, or return different rate curves per caller instead receives the router address for every routed renewal, collapsing distinct operators into one shared identity. The currently includedStaticRatePolicyandPausableStaticRatePolicyignorecaller, which limits immediate impact for those implementations, but the bug defeats a security property the ratifier interface and comments explicitly advertise.Recommendation
Clarify and enforce a single caller model across renewal execution. If caller-sensitive policies are not intended, document that
interestRatePolicymust be caller-insensitive and do not treat caller as a security boundary. If caller-sensitive policies are intended, implement that behavior in a separate router-specific entrypoint rather than changing the existingtakeflow in place. -
M-07 Medium onFlashLoan missing approval breaks bundled ops Logical Error Resolved
Description
midnightFlashLoanapprovesassetsto Midnight before callingMORPHO_MIDNIGHT.flashLoan, then relies on that allowance surviving the callback so Midnight can pull the repayment on return:// Midnight.flashLoan SafeTransferLib.safeTransfer(token, msg.sender, assets); // send to adapter IFlashLoanCallback(callback).onFlashLoan(token, assets, data); // run nested bundle SafeTransferLib.safeTransferFrom(token, msg.sender, address(this), assets); // pull back via allowanceThe adapter's callback forwards into the nested bundle and returns without restoring the allowance:
function onFlashLoan(address, uint256, bytes memory data) external { require(msg.sender == address(MORPHO_MIDNIGHT), ErrorsLib.UnauthorizedSender()); _midnightCallback(data); // no re-approval — Midnight will pull `assets` on return }Any adapter action dispatched inside the nested bundle that
forceApproves Midnight for the same token will overwrite the outer allowance, and Midnight will consume whatever is left on its next pull. Same-token culprits:midnightRepay,midnightSupplyCollateral(when collateral token equals the flashLoan token), a nestedmidnightFlashLoan, and an innertakethat routesonBuythrough this adapter. In every case the outersafeTransferFromsees allowance 0 and reverts, unwinding the bundle.onBuyin the same file demonstrates the correct pattern — it re-approves Midnight for the exact pull amount after the nested bundle runs.onFlashLoanhas identical post-callback pull semantics but does not mirror it.Impact: the canonical flash-loan flows (flashLoan → repay debt → withdraw collateral → swap → repay, or flashLoan → take an offer → repay) are unreachable whenever the flashLoan token is also touched inside the nested bundle.
Recommendation
Restore the outer allowance at the end of
onFlashLoan, mirroringonBuy. The callback already receivestokenandassets:function onFlashLoan(address token, uint256 assets, bytes memory data) external { require(msg.sender == address(MORPHO_MIDNIGHT), ErrorsLib.UnauthorizedSender()); _midnightCallback(data); SafeERC20.forceApprove(IERC20(token), address(MORPHO_MIDNIGHT), assets); } -
M-08 Medium Incorrect Math Inversion For SELL + fee Rounding Resolved
Description
ClampLib.maxUnitsForSellerBudget()usesmulDivDownInverse(floor inverse) to compute the maximum units for SELL offers withfeeRate > 0.However, the actual repay budget uses ceiling arithmetic (
mulDivUpviasellerFeeFromTick). Whenunits × effPrice % WAD ≠ 0, the floor inverse returns a unit count one too high — causingrepayBudget = sourceDebt + 1, which triggers anExcessRepaymentrevert in Midnight.This DoS affects fee-enabled full renewals via TakeRouter for SELL offers in the vast majority of real-world cases; the only safe inputs are those where
units × effPriceis exactly divisible by WAD (1e18), which is coincidental in practice.Recommendation
Replace
mulDivDownInversewith a plain floor division in the SELL+feeRate>0 branch ofmaxUnitsForSellerBudget:// Before (wrong): return mulDivDownInverse(maxBudget, WAD, effPrice); // After (correct): return maxBudget.mulDivDown(WAD, effPrice); -
M-09 Medium Executor hides real liquidator from gate Validation Resolved
Description
MidnightVaultExecutor.liquidateAndRedeem()callsMidnight.liquidate()as the executor contract, so the obligation'sliquidatorGateonly seesaddress(MidnightVaultExecutor). The function is public. It does not check whether the external caller would pass the same gate.This matters for caller-aware gates such as
MidnightAllowlistGate. If the gate allowlists the executor so vault-share liquidations can useliquidateAndRedeem(), any external account can call the executor and inherit that permission.Midnight.liquidate()accepts the call becausecanLiquidate(msg.sender)is evaluated against the executor. The executor then forwards the seized value to the original caller by encodingmsg.senderas theliquidatorand redeeming seized shares to that address inonLiquidate(). The caller still has to fundrepaidUnits, either by pre-funding the executor or by returning the repayment amount in its callback, but the caller-level liquidation restriction is gone.This is an authorization bypass in a documented executor-based liquidation setup. The project documentation explicitly describes
liquidateAndRedeem()as the liquidation mechanism where only the executor needs to be allowlisted and liquidators receive assets directly. If a market also relies onliquidatorGateto restrict which end liquidators may act, that policy is no longer enforced once the public executor is admitted. An unapproved liquidator can take the liquidation opportunity and spread even though the gate would reject that same account on a direct Midnight call.Recommendation
Do not treat
MidnightVaultExecutoras a safe public router for obligations that rely on caller identity atliquidatorGate.Consider mirroring the gate check on the external caller before forwarding the liquidation. If
obligation.liquidatorGate != address(0),liquidateAndRedeem()should requireILiquidatorGate(obligation.liquidatorGate).canLiquidate(msg.sender)and revert otherwise. -
M-10 Medium Zero oracle price gives free collateral Unexpected Behavior Acknowledged
Description
Midnight.liquidate()allows the liquidator to specifyseizedAssetsinstead ofrepaidUnits. In that mode, the function computes the repayment from the selected collateral oracle price:repaidUnits = seizedAssets.mulDivUp(liquidatedCollatPrice, ORACLE_PRICE_SCALE).mulDivUp(WAD, lif);If the selected collateral oracle returns zero,
repaidUnitsbecomes zero for any positiveseizedAssets. The function still subtracts the collateral from the borrower, adds zero towithdrawable, transfers the seized collateral to the liquidator, and finally pulls zero loan tokens from the liquidator.The same liquidation can also mark the borrower's debt as bad debt and update
lossIndex, so lenders can be slashed while the liquidator receives collateral for no repayment. Tenor'sOracleWithValidationCheckrejects a zero primary price, but Midnight obligations accept arbitrary oracle addresses. Any direct obligation or non-wrapper oracle that can transiently return zero exposes this path.Recommendation
Reject zero selected-collateral prices whenever collateral can be seized. For example, in
liquidate():if (seizedAssets > 0 && liquidatedCollatPrice == 0) revert ZeroCollateralPrice();More conservatively, require every active collateral oracle used in liquidation to return a nonzero price. Also document that a zero price is not just a liveness failure; in seized-asset liquidation mode it can become a value-extraction path.
-
M-11 Medium Keeper params do not guarantee profitability Logical Error Acknowledged
Description
Tenor collects a callback fee on every renewal, sent to a protocol-controlled
feeRecipient. There is no on-chain payment to the keeper. Tenor must run and subsidise the keeper itself.The fee scales with position size and interest discount (
fee = feeRate × (1 − price) × units), while keeper gas is fixed per renewal. For short renewal durations or small positions the fee falls below gas cost (around 415k gas spent on renewal). Adding more collaterals make it worse — each adds roughly 93k ($0.47 at 2 gwei) with no effect on the fee.Measured gas (2 gwei, $2,500/ETH): $2.08 for 1 collateral, $3.94 for 5 collaterals.
At current market rates (4–5% APR), a $1,000 position renewed weekly earns $0.50 in fees per renewal against $2.08–$3.94 in gas — a $1.58–$3.44 loss per execution. Even a monthly renewal at 5% APR on a $1,000 position earns only $2.11, leaving no margin for gas spikes or additional collaterals. No minimum position size or renewal duration is enforced.
Recommendation
Document the break-even threshold explicitly (position size, renewal duration, collateral count, target chain) so keeper can make an informed decision.
If self-sustaining keeper economics are required, enforce limits so
fee ≥ gas_costbefore executing. -
M-12 Medium Temporary grants bypass router limits Validation Acknowledged
Description
RenewalIntentTakeOnBehalf.take()has no action-scoped authorization and no reentrancy guard. It only checks that the user's ratifier is currently authorized, calls that ratifier, then forwards the take to Midnight.This is unsafe when users rely on Bundler3 to grant renewal permissions temporarily.
AuthorizationAdaptercan grantRenewalIntentTakeOnBehalfand the user's renewal ratifier at the start of a multicall, runTenorAdapter.execute(), then revoke those permissions at the end. During the execution, those permissions are global.TakeRouteronly accounts for the amounts returned by the outer_TAKE_ON_BEHALF.take()call. If a malicious maker ratifier is called insideMidnight.take(), it can callRenewalIntentTakeOnBehalf.take()again for the same user while the temporary grants are still active. The nested renewal can pass the user's ratifier and Midnight checks, but it is invisible to the outer router. Therefore it is not included inmaxFill,minFill, slippage checks,BatchExecuted, orexecuteAndConsumeaccounting.For example, a lender submits a bundled renewal capped at
100units and grants the needed permissions only for that bundle. The maker ratifier reenters during the first take and executes another valid renewal for100units before the outer router receives a result. The outer router then completes the original100unit fill and reports that amount. The user's position changed by200units even though the transaction-level router limit was100.Recommendation
Do not use global boolean authorization as the only control for bundled renewals. Bind renewal authorization to a concrete execution scope that includes the expected caller, callback, source market, target market, expiry or nonce and maximum units. Consume that authorization before calling any untrusted ratifier or callback.
As an immediate hardening step, add a reentrancy guard to
RenewalIntentTakeOnBehalf.take()so a maker ratifier, callback, token hook, or other synchronous external call cannot start another renewal before the first one returns. The router should also reject or explicitly account for nested orchestrator execution if nested renewals are intended to be supported. -
M-13 Medium Flash callback spends pending reentry Validation Acknowledged
Description
MidnightAdapterBase.onFlashLoan()forwards any Midnight flash-loan callback data toBundler3.reenter()after only checking thatmsg.senderisMORPHO_MIDNIGHT. It does not record thatTenorAdapterstarted the flash loan throughmidnightFlashLoan(). It also does not check the expected token, amount, callback data hash, offer, or router action before forwarding the reentry data.Midnight.flashLoan()lets the caller choose both the callback address and the callback data. It also acceptsassets == 0. Therefore, while Bundler3 is executingTenorAdapter.execute()with a pendingcallbackHash = keccak256(reentryData), a malicious maker ratifier can call:MORPHO_MIDNIGHT.flashLoan(token, 0, address(TenorAdapter), reentryData);Midnight then calls
TenorAdapter.onFlashLoan(token, 0, reentryData). The adapter forwardsreentryDatato Bundler3. Bundler3 accepts the reentry becauseTenorAdapteris the caller and the reentry bytes match the pending hash. This consumes the user's precommitted reentry before the intended callback uses it.The ratifier must know the exact reentry bytes. This can happen if the route passes those bytes in
ratifierData, embeds them in maker-controlled offer data, or uses predictable callback data. The attacker is not granted arbitrary Bundler execution. Bundler3 still executes the user's precommitted calls, but it executes them at the wrong time.This matters because
TenorAdapteris used as a temporary payer inside a bundled transaction. For a directMIDNIGHT_TAKEof a sell offer with no taker callback,TakeRouter._dispatchMidnightTake()gives Midnight a temporary allowance fromTenorAdapter. Midnight also treatsTenorAdapteras the payer. If the premature reentry withdraws the victim's source credit into the adapter before settlement, the resumedMidnight.take()can pull those tokens from the adapter and pay them to the maker.The impact is stronger when the direct
MIDNIGHT_TAKEtargets an untrusted Midnight obligation. Obligations are created permissionlessly andtouchObligation()only checks structural fields such as collateral ordering, allowed LLTV andmaxLif. It does not whitelist collateral tokens or oracles. A malicious maker can publish a sell offer whose loan token is the real loan token, but whose collateral is a worthless attacker token and whose oracle is controlled by the attacker.In that scenario, the premature reentry withdraws the victim's source credit to
TenorAdapterbefore the attacker offer settles. Midnight then pulls the newly withdrawn loan tokens from the adapter and pays the maker. The victim receives credit in the attacker's obligation instead of keeping the original source credit.The attacker-controlled oracle can report a high price during settlement so the seller health check passes. After settlement, the oracle can report zero. A zero-repay liquidation then realizes the attacker's debt as bad debt, clears the attacker debt and slashes the victim's toxic credit through the obligation loss index.
This requires a direct
MIDNIGHT_TAKEsourced from an untrusted maker or obligation, exposed or predictable reentry bytes, withdrawable source credit and passing route price checks. It does not affect passive users. It also does not affect users routed only through canonicalORCHESTRATOR_TAKEexecution with ratifier market-id binding. If no adapter reentry hash is pending, the malicious flash-loan callback cannot satisfy Bundler3's reentry check and the take reverts.Recommendation
Only allow
onFlashLoan()during a flash loan started bymidnightFlashLoan(). Set a transient guard before callingMORPHO_MIDNIGHT.flashLoan(token, assets, address(this), data). Store the expected token, amount and callback data hash. Require those values inonFlashLoan()before calling_midnightCallback(data), then clear the guard after the flash loan returns.Also bind adapter reentry data to the callback that is allowed to use it. The adapter should verify an expected callback selector, obligation id, taker role, subject address and offer or action hash before forwarding the inner
Call[]to Bundler3.For router-level defense in depth, raw
MIDNIGHT_TAKEexecution should reject offers whose obligation id or market id is not explicitly expected. The check should bindoffer.obligationto an allowlisted target market or to the same market id authorized by the user's renewal intent. -
M-14 Medium Source unlocked during borrow renewal Reentrancy Acknowledged
Description
Midnight.take()only applies the transient liquidation lock to the seller on the obligation being traded. In a V2-to-V2 borrow renewal, that traded obligation is the target obligation. The source obligation is different, butMorphoV2ToV2BorrowRepay.onSell()later repays that source debt and moves source collateral.When the matched offer has
offer.buy == true, the maker is the buyer andoffer.callbackruns as the buyer callback before the user's seller callback. The canonical renewal ratifier validates the user's renewal callback data, but it does not reject a nonzero maker-side buyer callback. Consequently, a maker can sign a BUY offer whose callback touches the user's source obligation before the renewal callback repays it.If the source position is already unhealthy, past maturity, or otherwise eligible for liquidation-related actions, the maker callback can call
DelayedLiquidationGate.startGracePeriod()on the source obligation or partially liquidate the source before the renewal repairs it. A full source liquidation usually makes the later repayment revert becauserepayBudgetcan exceed the remaining source debt. The practical profitable case is a partial liquidation that leaves enough source debt forMorphoV2ToV2BorrowRepay.onSell()to continue, letting the attacker capture liquidation premium inside the same renewal transaction. The grace-period case can also leave a source timer armed even if the renewal later makes the source healthy.Recommendation
Lock every Midnight obligation that a borrow renewal will mutate, not only the traded target obligation. For V2-to-V2 borrow renewals, the source obligation should be locked for the borrower before
Midnight.take()executes any buyer callback.If Midnight cannot support multi-obligation locks, the renewal ratifier or wrapper should reject nonzero maker-side buyer callbacks for borrow renewals where the user is the seller. An allowlist is also acceptable if only audited inert callbacks are permitted. Do not rely on the token reentrancy assumption as the mitigation, because this vector uses the normal buyer callback order.
-
M-15 Medium executeAndConsume burns keeper consumeGroup Logical Error Resolved
Description
executeAndConsumeis supposed to couple a routed fill with aconsumedupdate for the user whose position is being filled. The batch itself is executed on behalf ofparams.taker:TakeRouterauthorizes the caller asparams.takeror a Midnight-approved delegate, and the downstream takes useparams.takeras the taker identity.However, the final
setConsumedcall usesinitiator()instead ofparams.taker:function executeAndConsume(ExecuteParams calldata params, Action[] calldata actions, bytes32 consumeGroup) external onlyBundler3 returns (uint256, uint256, uint256) { (uint256[3] memory totals, uint256[3] memory rawTotals) = _executeResolvingSentinels(params, actions); address caller = initiator(); _MIDNIGHT.setConsumed( consumeGroup, _MIDNIGHT.consumed(caller, consumeGroup) + rawTotals[params.fillIndex], caller // BUG: consumes the bundle initiator, not `params.taker` ); return (totals[0], totals[1], totals[2]); }- A user authorizes a keeper on Midnight and relies on
executeAndConsumeto fill a self-limiting order group. - The keeper calls the adapter with
params.taker = user. - The batch moves the user's position and returns non-zero
rawTotals. - The adapter updates
consumedfor the keeper's namespace, leavingconsumed[user][consumeGroup]unchanged.
As a result, the user's order/group remains live even though the routed fill already happened. Any flow that relies on
consumeGroupto cap or one-shot a user-owned order can therefore be overfilled or replayed through delegated execution.Recommendation
Replace
initiator()withparams.takerin the finalsetConsumedpath. - A user authorizes a keeper on Midnight and relies on
-
M-16 Medium Keeper adapter callbacks reject delegation Logical Error Resolved
Description
TakeRouterAdapterBaseexplicitly supports delegated execution: the bundle submitter can differ fromparams.takeras long as the taker has authorized that caller on Midnight.This means a keeper can submit a Bundler3 bundle on behalf of a user and still pass the router's access control.
However, the adapter's own Midnight callbacks reject that same delegated shape.
onBuyandonSellrequire the Midnightbuyerorsellerto equalinitiator():function onBuy(...) external returns (bytes32) { require(msg.sender == address(MORPHO_MIDNIGHT), ErrorsLib.UnauthorizedSender()); require(buyer == initiator(), ErrorsLib.UnauthorizedSender()); // bundle submitter must be buyer _midnightCallback(data); SafeERC20.forceApprove(IERC20(obligation.loanToken), address(MORPHO_MIDNIGHT), buyerAssets); return CALLBACK_SUCCESS; } For `MIDNIGHT_TAKE`, Midnight passes the actual taker user into those callback roles, not the delegated caller: ```solidity // sell offer: taker is buyer ( taker, takerCallback, takerCallbackData, offer.maker, offer.callback, offer.callbackData, offer.receiverIfMakerIsSeller )As a result, any route that uses
TenorAdapteritself as the taker callback works only when the user submits the bundle directly. If an authorized keeper submits the same bundle,initiator()resolves to the keeper while Midnight still passesbuyer == params.takerorseller == params.taker, and the adapter callback reverts.Recommendation
buyer/sellershould be validated against the taker authorization model, not strict equality withinitiator. Mirror authorization in TakeRouter:address caller = initiator(); require( buyer == caller || MORPHO_MIDNIGHT.isAuthorized(buyer, caller), ErrorsLib.UnauthorizedSender() ); -
M-17 Medium Adapter callbacks cannot batch reentries Compatibility Partially resolved
Description
TakeRouteris designed to fill a user's position across multiple actions in one call.TenorAdapterexposes that router through Bundler3, and its Midnight callbacks reenter Bundler3 through_midnightCallback.The problem is that Bundler3 only supports one expected reentry per top-level call. Before each call in
multicall, it stores a singlereenterHashderived from that call'scallbackHash:for (uint256 i; i < bundle.length; ++i) { address to = bundle[i].to; bytes32 callbackHash = bundle[i].callbackHash; if (callbackHash == bytes32(0)) reenterHash = bytes32(0); else reenterHash = keccak256(bytes.concat(bytes20(to), callbackHash)); (bool success, bytes memory returnData) = to.call{value: bundle[i].value}(bundle[i].data); if (!bundle[i].skipRevert && !success) UtilsLib.lowLevelRevert(returnData); require(reenterHash == bytes32(0), ErrorsLib.MissingExpectedReenter()); }That is incompatible with a single
adapter.executecall that dispatches multipleMIDNIGHT_TAKEactions where more than one action usesTenorAdapteras its taker callback.TakeRouterloops through all actions inside the same top-level call and the first adapter callback consumes the only pending reentry:function onSell(...) external returns (bytes32) { require(msg.sender == address(MORPHO_MIDNIGHT), ErrorsLib.UnauthorizedSender()); require(seller == initiator(), ErrorsLib.UnauthorizedSender()); _midnightCallback(data); // consumes the only pending reenterHash return CALLBACK_SUCCESS; }After that callback returns,
reenterHashis cleared. If a later action in the sameadapter.executecall reaches another adapter callback,_midnightCallbackcallsBundler3.reenteragain with no matching pending hash. Bundler3 reverts withIncorrectReenterHash, causing the action or the whole batch to fail.A concrete example:
- Alice submits one
adapter.executebatch with two actions, filling Bob's offer and Carol's offer. - Both actions set
takerCallback = address(TenorAdapter)because each fill needs a reentrant Bundler3 funding step. - Bundler3 stores one
reenterHashfor the outeradapter.executecall. - Bob's fill reaches
TenorAdapter.onSell, callsBundler3.reenter, and consumes that hash. - Carol's fill later reaches
TenorAdapter.onSellin the same router loop, callsBundler3.reenteragain, and reverts becausereenterHashis already zero.
Recommendation
Consider a redesign of Tenor adapter operations around Bundler3's one-reentry-per-top-level-call model.
- Alice submits one
-
M-18 Medium executeAndConsume accepts intervening fills Unexpected Behavior Acknowledged
Description
executeAndConsume()executes the whole router batch before it reads the user's currentconsumedvalue forconsumeGroup. It then writescurrentConsumed + rawTotals[params.fillIndex].(uint256[3] memory totals, uint256[3] memory rawTotals) = _executeResolvingSentinels(params, actions); address caller = initiator(); _MIDNIGHT.setConsumed( consumeGroup, _MIDNIGHT.consumed(caller, consumeGroup) + rawTotals[params.fillIndex], caller );This is unsafe when
executeAndConsume()is used to implement an OCO-style action, where a user executes one route and wants the same transaction to consume or cancel a standing offer group._executeResolvingSentinels()can call untrusted Midnight ratifiers, maker callbacks, taker callbacks, token transfers, and fee adjusters before the finalsetConsumed()call. Any of those external components can fill the user's live offer inconsumeGroupbeforeexecuteAndConsume()performs its final write.Midnight's own
take()checks the offer cap while it fills the offer. For a unit-capped offer, it incrementsconsumed[offer.maker][offer.group]and requires the result to be at mostoffer.maxUnits. However,setConsumed()is a separate monotonic setter. It only requires the new value to be greater than or equal to the current value. It does not know the max value of any signed offer in that group, and it intentionally accepts values above the offer cap.Consequently, a group can be filled once through the live offer and then advanced again by
executeAndConsume()in the same transaction. For example, Alice has a standing sell offer in groupGwithmaxUnits = 100. Alice submitsexecuteAndConsume(..., consumeGroup = G)to sell100units elsewhere and markGas consumed. During the router execution, a malicious maker callback fills Alice's standing offer for100units. The standing offer's own fill is valid, so Midnight setsconsumed[Alice][G] = 100. The router then completes Alice's other100unit fill. Finally,executeAndConsume()reads the now-current consumed value and setsconsumed[Alice][G] = 200.The transaction does not revert even though Alice's standing offer was capped at
100units. Alice experiences both fills. The impact is not arbitrary theft because the intervening fill must use an offer Alice already made available, and the router fill must also pass Alice's submitted route constraints. The broken property is the OCO guarantee: one fill is supposed to consume the shared capacity before the other can execute. A malicious counterparty, ratifier, or callback reached by the route can force both sides to execute under realistic conditions. The same race exists across transactions when the live offer is filled in the mempool before Alice'sexecuteAndConsume()transaction lands, because the adapter has noexpectedConsumedinput.Recommendation
Make
executeAndConsume()bind the final write to an expected startingconsumedvalue. The caller should provideexpectedConsumed, and the adapter should check it before any external execution. After the router returns, the adapter should require that the sameconsumeGroupis still unchanged before writing the final value.function executeAndConsume( ExecuteParams calldata params, Action[] calldata actions, bytes32 consumeGroup, uint256 expectedConsumed ) external onlyBundler3 returns (uint256, uint256, uint256) { address caller = initiator(); uint256 beforeConsumed = _MIDNIGHT.consumed(caller, consumeGroup); require(beforeConsumed == expectedConsumed, ConsumedChanged()); (uint256[3] memory totals, uint256[3] memory rawTotals) = _executeResolvingSentinels(params, actions); require(_MIDNIGHT.consumed(caller, consumeGroup) == beforeConsumed, ConsumedChanged()); _MIDNIGHT.setConsumed( consumeGroup, beforeConsumed + rawTotals[params.fillIndex], caller ); return (totals[0], totals[1], totals[2]); }If the intended behavior is hard reservation rather than exact OCO accounting, pre-consume
expectedConsumed + maxFillbefore external calls and then document that unused capacity is burned. If delegated execution remains supported, apply the same snapshot to the account whose group is being consumed. -
M-19 Medium Clamps ignore revoked callback auth / allowances DoS Acknowledged
Description
Several withdrawal-backed clamps size actions from balances, debt, or withdrawable liquidity only, but they do not verify whether the callback still has the authorization or token allowance required to execute the action.
Examples:
VaultWithdrawClampassumes the callback can withdraw that collateral from Midnight on the user's behalf.V2ToV2LendWithdrawableClampassumes the callback is still authorized to callMidnight.withdraw(...).V1ToV2LendClampdoes not check whether the vault-share allowance toMorphoV1ToV2LendCallbackis still in place.
Those assumptions are revocable. A user can:
- revoke Midnight authorization for the callback contract
- revoke the callback's ERC20 / ERC4626 allowance
- leave an old offer signed and otherwise valid
After that, the clamps still report the offer as fillable because they only inspect economic state. But the real callback path reverts when it attempts the privileged operation.
The clamp output a likely offchain executability signal for production keepers, especially on callback-heavy routes where the callback-specific clamp is the intended way to avoid reverts.
Because of that, the issue creates a real griefing vector:
- A user signs a callback-backed offer and leaves it visible to the keeper network.
- The same user later revokes the callback authorization or allowance needed by the callback path.
- The keeper still sees a positive clamp result, because the clamp only checks economic state.
- The keeper routes the action onchain.
- The callback reverts on the missing auth / allowance.
- If the action is batched with
allowRevert = false, the entire batch aborts, including unrelated fills for other users.
The attacker cost is low: they do not need to break pricing or capital constraints, only to publish a seemingly valid signed offer and then revoke the callback permission that the clamp forgot to model.
The impact is an actionable DoS / gas-griefing vector against keeper infrastructure:
- takers and keepers can be lured into reverted fills
- routed batches can be deterministically reverted when
allowRevertis not set - unrelated users in the same keeper batch can be denied execution
- off-chain systems that treat clamp output as a reliable executability signal will overestimate available liquidity
Recommendation
For each clamp, include the callback's required authority in the view check when that authority is cheap to read on-chain.
-
M-20 Medium Tenor proritizes Midnight's trading fees Logical Error Acknowledged
Description
Tenor callback fees are computed from the actual asset amount passed by Midnight into the callback, but that amount may already include Midnight's target obligation trading-fee adjustment. As a result, Midnight trading fees can consume the Tenor fee spread and cause a configured nonzero Tenor callback fee to settle as zero.
This was originally observed in
MorphoV2ToV2BorrowRepay.onSell(). For BUY offers, Midnight deducts the target obligation trading fee from the borrower'ssellerAssetsbefore calling the borrower-side seller callback. The callback then compares that already-reducedsellerAssetsagainst a seller effective price derived only fromoffer.tickand the Tenor fee rate. When the target Midnight trading fee is larger than the Tenor fee spread,CallbackLib.sellerFeeFromTick()zero-floors the fee to0. The source debt is still repaid and collateral is migrated, so the renewal succeeds while the configured Tenor fee recipient receives nothing.The same issue also affects buyer-side lender flows. In
MorphoV2ToV2LendWithdrawable.onBuy()andMorphoV1ToV2LendCallback.onBuy(), Midnight passesbuyerAssetsafter adding the target obligation trading fee. Both callbacks compute the Tenor fee withCallbackLib.buyerFeeFromTick(..., buyerAssets), which compares the Tenor buyer budget against the already-inflatedbuyerAssetsand zero-floors the result. If the Midnight trading fee exceeds the Tenor buyer-side fee spread, the lender renewal or migration still succeeds and the lender receives target credit, but the Tenor fee recipient receives no fee.Recommendation
Define the intended Tenor fee base explicitly and make callback fee calculation trading-fee-aware. If Tenor fees should apply independently of Midnight trading fees, compute Tenor fees from the tick-priced raw asset amount before Midnight target trading fees are applied, or pass both raw tick assets and trading-fee-adjusted settlement assets into the callback.
Do not compute Tenor fees solely from
sellerAssetsorbuyerAssetsafter Midnight trading-fee adjustment. If Midnight trading fees intentionally take priority, document that nonzero Tenor fee configs can settle with zero callback fee across both borrower-side and lender-side renewal flows. -
M-21 Medium Keeper gets full Midnight control Access Control Acknowledged
Description
Normal renewal execution through
RenewalIntentTakeOnBehalf.take()does not require the user to authorize a keeper on Midnight. The renewal orchestrator is permissionless at the caller layer: any caller can submit a valid renewal, while the user-controlled protections come fromuser -> takeOnBehalfauthorization on Midnight anduser -> ratifierauthorization on the renewal orchestrator. The ratifier then enforces the source/target markets, maturity window, rate policy, callback schema, and fee config.The delegated Router path adds a different requirement.
TakeRouter._execute()allows a non-user caller only whenMidnight.isAuthorized(params.taker, caller)is true. For direct Router execution this means the user must authorize the keeper address. ForTenorAdapter,_caller()resolves to the Bundler initiator, so the same condition applies to the keeper/initiator. The Router or adapter must also be authorized on Midnight so it can callMidnight.take(... taker = user ...).That keeper authorization is not scoped to Router or renewal execution. It is full Midnight operator authority. Once granted, the keeper does not need Router at all: it can call Midnight directly as the user, bypassing
RenewalIntentTakeOnBehalfand all renewal-ratifier checks. A malicious or compromised keeper can accept arbitrary valid offers as the user, choosereceiverIfTakerIsSeller, withdraw the user's credit to any receiver, withdraw collateral while health checks allow, cancel offers viasetConsumedorshuffleSession, and grant further authorization withsetIsAuthorized.This is a privilege-scope mismatch. Users may reasonably understand a keeper approval as limited automation for renewal fills, but the approval needed by the Router keeper path gives the keeper direct control over the user's Midnight account.
Recommendation
Do not require users to grant keepers direct Midnight authorization for Router-based renewal automation. Route delegated execution through a scoped contract authorization instead: the user should authorize a contract that enforces renewal-only action types, ratifier checks, receiver constraints, and callback schemas before calling Midnight. Alternatively, add a scoped authorization layer in Midnight or the Router that distinguishes "can call Router renewal flow" from "can operate all Midnight account functions."
If the intended model is that keepers are fully trusted Midnight operators, document this explicitly in user-facing integration docs and avoid presenting keeper approval as limited renewal automation.
-
M-22 Medium TenorAdapter Bypasses Midnight's Repay Auth Validation Resolved
Description
Midnight's account authorization check trusts
msg.sender: a caller can act foronBehalfonly ifonBehalf == msg.senderorisAuthorized[onBehalf][msg.sender]is true. Once a user authorizesTenorAdapter, Midnight correctly trusts calls from that adapter.The adapter must therefore bind every forwarded account action to the real Bundler3
initiator(). However,midnightRepay()fails to do this and accepts arbitraryonBehalf, so any unrelated Bundler3 initiator can callTenorAdapter.midnightRepay(sourceObligation, 1, 0, victim). Midnight seesmsg.sender == TenorAdapter, so the victim's adapter authorization is enough for the repay to pass even though the actual bundle initiator is not authorized by the victim.Impacts:
- exact V2-to-V2 borrow renewals can be reverted. A 1 wei adapter repay lowers live source debt while the signed renewal still uses the old exact
repayBudget;MorphoV2ToV2BorrowRepay.onSell()then reverts withExcessRepayment. - exact V2-to-V1 borrow exits can be reverted. The same dust repay makes an old exact BUY fill cross the borrower from debt into tiny credit, causing
MorphoV2ToV1BorrowCallbackto revert onPositionCrossing. - reduce-only BUY offers can be invalidated. If a previously exact debt-reducing fill now exceeds live debt by 1 wei, Midnight rejects it because the maker would increase credit despite
reduceOnly. - exact repayment liquidations can be invalidated. A liquidator transaction quoted with
repaidUnits == borrowerDebtbecomes stale after a 1 wei repay and reverts, so liquidation bots must re-read and requote before retrying.
Recommendation
Pin
midnightRepay()to the current Bundler3 initiator, matching the rest of the account-mutating adapter surface - exact V2-to-V2 borrow renewals can be reverted. A 1 wei adapter repay lowers live source debt while the signed renewal still uses the old exact
-
M-23 Medium Arbitrary policies can gas grief keepers DoS Acknowledged
Description
A malicious user can install an arbitrary
interestRatePolicy, andBaseRenewalRatifierwill call it on every renewal throughgetRate.The policy could perform arbitrarily expensive read-only work before returning or reverting. Users fully control the policy address, and the docs describe
StaticRatePolicyandPausableStaticRatePolicyas examples rather than an allowlisted set.A malicious policy can:
- burn substantial gas through expensive read-only logic before reverting,
- selectively reverts based on
caller,block.timestamp, or other execution context, - passes one offchain simulation and fails during real inclusion when the environment changes slightly.
This is more impactful than ordinary revoked auth / allowance grief. Those failures are typically cheap; here the router only catches the failure after the policy has already consumed most of the gas forwarded (EIP-150 63/64 rule) into
_TAKE_ON_BEHALF.take. Hence,allowRevert = trueis ineffective here as most of the gas budget is lost, preventing subsequent actions from continuing.Recommendation
Consider capping the gas forwarded per
takeOnBehalfaction inTakeRouter(or alternatively at thegetRatestep) so that a single bad policy does not drain the keeper's remaining gas budget andallowRevertremains effective in practice.More defensively, consider allowlisting approved
interestRatePolicyimplementations, or requiring policies to come from a trusted factory. -
M-24 Medium Permissionless first touch freezes default fees Unexpected Behavior Acknowledged
Description
touchObligation()can be called by anyone and the first caller copies the current default trading and continuous fees into the obligation. Later calls tosetDefaultTradingFee()orsetDefaultContinuousFee()do not affect obligations that were already touched. The fee setter can repair a known touched obligation withsetObligationTradingFee()andsetObligationContinuousFee(), but default fee updates will not repair it automatically.Consequently, a searcher can pre-create predictable future obligations before the fee setter raises defaults. Those obligations keep the old fee schedule even after governance expects new markets for the same loan token to use higher fees. Users and keepers can then trade against the pre-created obligations and avoid both the trading fee and the continuous fee until each obligation is discovered and patched.
For a concrete Tenor case, assume USDC renewals are expected to use a known collateral set, oracle set, LLTV, gate configuration, and four-week maturity cadence. During launch, the default Midnight fees for USDC are still zero or temporarily lower. A searcher can enumerate the next several Tenor target obligations from those public parameters and call
touchObligation()for each future maturity. When Morpho later raises the USDC default trading and continuous fees, those pre-created target obligations keep the old schedule. A borrower or lender can then route renewals throughRenewalIntentTakeOnBehalfinto the pre-created obligations and settle at the intended Tenor market and maturity, but Midnight charges the stale fees stored on first touch. The same renewal into an otherwise identical target obligation created after the fee update would accrue protocol fees.The obligations can be created by anyone, the future Tenor obligation ids are predictable from public market parameters and skipped fees are not recoverable. Any trading fee skipped before the obligation-specific repair is permanently lost. Continuous-fee liability for credit opened while the stale fee was active is also fixed from the old
continuousFee. A later repair only applies to new credit opened after the patch. It does not retroactively charge the correct liability to already-opened credit, so existing credit keeps the stale fee value through maturity.Recommendation
Do not let arbitrary callers lock in protocol defaults for future obligations. Either require an authorized fee setter or market creator to initialize fee-bearing obligations or make untouched obligations read current defaults until the first real position-changing action.
If immutable per-obligation fees are required, Tenor should treat fee initialization as an explicit market-activation step. Before allowing renewals into a known obligation, compute its id, call
touchObligation()if needed and have Midnight'sfeeSettercallsetObligationTradingFee()for all seven trading-fee breakpoints plussetObligationContinuousFee()for that obligation. The ratifier can also reject target obligations whose storedtradingFees(id)orcontinuousFee(id)do not match the expected schedule.This recovery only protects future trades after the obligation-specific fee update. It cannot recover trading fees already skipped. It also cannot retroactively assign the correct continuous-fee liability to credit that was opened while the stale fee was active.
-
M-25 Medium Fresh credit drains old repayments Validation Acknowledged
Description
Midnighttracks repaid cash in one obligation-levelwithdrawablebucket.repay()adds units to that bucket, andwithdraw()lets any current credit holder burn credit and pull from the bucket. There is no accounting that ties a repayment to the lenders whose credit existed when that repayment happened.That lets a new buyer take a discounted SELL offer, receive fresh credit, and immediately redeem that fresh credit against old repayments at par. For example, if an obligation already has
100e18withdrawable units and a borrower sells100e18new units at a discounted price, the buyer can pay less than100e18, receive100e18credit, then callwithdraw()for100e18. The buyer captures the discount while the old lenders' repaid liquidity is drained and replaced with exposure to the new borrower's debt.This also explains the callback self-funding variants.
Midnight.take()updates buyer credit before callbacks and before final payment settlement. If a callback path can callwithdraw()on the same obligation, the fresh credit created by the in-flight take is treated as source credit for oldwithdrawableliquidity:MorphoV2ToV2LendWithdrawable.onBuy()can be called withsourceObligation == obligation, so direct callback data can withdrawbuyerAssets + feefrom the same obligation immediately after the buyer credit is minted.MidnightAdapterBase.onBuy()can reenter Bundler3 before approving Midnight to collectbuyerAssets, and the reentered bundle can callmidnightWithdraw()against the same obligation.
Those callback routes remove the buyer's upfront capital requirement, but they are not a separate economic root cause. They are same-transaction manifestations of the broader issue: newly created credit can claim older repayments from the global
withdrawablepool. Even without a callback, the buyer can fund the discounted purchase with their own balance or external flash liquidity and withdraw old repayments after the take settles.If the offer uses a zero-price tick, the same entitlement bug becomes a zero-principal version of the attack. The existing zero-price cap issue covers the cap-accounting footgun, but the loss here is the ability of newly created credit to claim older repayments.
The root cause is that
take()creates transferable/redeemable buyer credit immediately:uint256 buyerCreditIncrease = UtilsLib.zeroFloorSub(units, buyerPos.debt); uint256 sellerCreditDecrease = UtilsLib.min(units, sellerPos.credit); uint256 sellerDebtIncrease = units - sellerCreditDecrease; buyerPos.debt -= UtilsLib.toUint128(units - buyerCreditIncrease); buyerPos.pendingFee += buyerPendingFeeIncrease; buyerPos.credit += UtilsLib.toUint128(buyerCreditIncrease); sellerPos.pendingFee -= sellerPendingFeeDecrease; sellerPos.credit -= UtilsLib.toUint128(sellerCreditDecrease); sellerPos.debt += UtilsLib.toUint128(sellerDebtIncrease); _obligationState.totalUnits = UtilsLib.toUint128(_obligationState.totalUnits + buyerCreditIncrease - sellerCreditDecrease);Then
withdraw()only checks the caller's current credit and the obligation's globalwithdrawablebalance. It never checks whether that credit existed when the repaid liquidity entered the bucket:_updatePosition(obligation, id, onBehalf); Position storage _position = position[id][onBehalf]; uint128 pendingFeeDecrease; if (_position.credit > 0) { pendingFeeDecrease = UtilsLib.toUint128(_position.pendingFee.mulDivUp(units, _position.credit)); _position.pendingFee -= pendingFeeDecrease; } _position.credit -= UtilsLib.toUint128(units); _obligationState.withdrawable -= UtilsLib.toUint128(units); _obligationState.totalUnits -= UtilsLib.toUint128(units); SafeTransferLib.safeTransfer(obligation.loanToken, receiver, units);Recommendation
Make repayment entitlement position-indexed instead of first-come-first-served. A typical fix is to maintain a global withdrawal index that increases on repayment, track each position's index and initialize newly created credit at the current index so it cannot claim older repayments.
As a blunt mitigation, reject debt-increasing takes while an obligation has nonzero
withdrawable, but that is likely too restrictive. The durable fix is to separate old repayment claims from newly minted credit.Callback-specific guards are still useful defense in depth.
MorphoV2ToV2LendWithdrawable.onBuy()should rejectsourceObligationId == targetObligationId, and generic adapter buy callbacks should not be allowed to withdraw newly created same-obligation credit during the in-flight take. Those local guards reduce the no-capital self-funding paths, but they do not fix the post-settlement version where a buyer funds the discounted purchase and then withdraws old repayments at par.Follow up issue / Tenor exploitation path described in:
-
L-01 Low allowRevert misses pre-dispatch reverts Unexpected Behavior Acknowledged
Description
allowRevertonly catches failures from_TAKE_ON_BEHALF.take()and_MIDNIGHT.take(). Both dispatch helpers do meaningful work before reaching thosetry/catchblocks. They decode action data, call_MIDNIGHT.touchObligation()and call_capTakeUnits()._capTakeUnits()then reachesRouterLib.budgetToUnits(),ClampLib.getOfferRemaining()and any caller-supplied fee adjuster or clamp.Those operations can revert before the router has entered the soft-fail boundary. For example,
ClampLib.getOfferRemaining()computesofferPrice - tradingFeefor buy offers without a zero floor. A low-tick buy offer can therefore underflow before_TAKE_ON_BEHALF.take()or_MIDNIGHT.take()is attempted. Similarly, asset-denominated sizing can revert insideTakeAmountsLibbefore the action reaches the catch block.Consequently a single malformed or adversarial offer can abort the entire batch even when its
Action.allowRevertflag is set. This contradicts the documented soft-failure behavior and creates a griefing target for keepers that aggregate untrusted actions.Recommendation
Move all maker-controlled pre-dispatch work inside the same failure-isolation boundary as the downstream take. A practical fix is to wrap obligation touching and unit capping in an internal call that can be caught, then convert any failure into
(false, 0, 0, 0, reason)whenallowRevertis enabled.Also normalize known sizing edge cases, such as zero prices and
tradingFee > offerPrice, so they return a safe zero cap where possible instead of panicking before the action can be skipped. -
L-02 Low Frozen gate trusts fee recipients Trust Assumptions Acknowledged
Description
canReceiveSharesdoes not rely only on the gate's ownallowlist. It also returnstruefor whatever addressesmsg.sendercurrently exposes asmanagementFeeRecipient()andperformanceFeeRecipient().That means
renounceOwnership()does not freeze the effective receive-share whitelist.VaultV2keepssetManagementFeeRecipientandsetPerformanceFeeRecipientas live curator actions, so the curator can nominate any non-zero address after the gate owner has burned control. The new recipient becomes immediately eligible to receive shares even if it was never present inallowlist. This also works when both fee rates are zero, because the gate checks only the recipient fields.This matters because the gate is documented as the mechanism that keeps vault shares away from secondary markets and lending venues. A curator can later reopen that surface for an arbitrary EOA or protocol without touching the gate mapping and without re-enabling
setReceiveSharesGate. Consequently, the "immutable allowlist" guarantee is weaker than it appears, and the share-receive restriction can be relaxed after the setup is supposedly frozen.Recommendation
Do not derive share-receive permission from live vault fee-recipient fields. Require fee recipients to be explicitly allowlisted, then freeze only the gate's own storage. If the auto-whitelist must remain, the deployment process should also abdicate both fee-recipient setters before the gate is treated as frozen and that requirement should be documented clearly.
-
L-03 Low Vault-share exit ignores vault withdrawal limits Validation Acknowledged
Description
onBuysizes the collateral pull withpreviewWithdraw(buyerAssets)and then immediately callswithdraw(buyerAssets, ...). That only proves how many shares are needed at the current exchange rate. It does not prove thatbuyerAssetsis actually withdrawable from the vault.ERC4626 explicitly separates
previewWithdrawfrom withdrawal limits.previewWithdrawmust ignore limits such as queues, cooldowns and liquidity ceilings, whilewithdrawmust revert if the requested assets cannot actually be withdrawn. This callback never checks any executable withdrawal bound after it receives the buyer's shares, so a position can hold enough vault-share value on Midnight while the vault still refuses to redeem the requested assets. In that state the callback reverts insidewithdraw, which makes the BUY offer unfillable.This is a liveness issue on its own. It becomes a taker-griefing issue because
VaultWithdrawClampbounds fills withconvertToAssets(userVaultShares)instead of redeemable liquidity. Routers and keepers can therefore derive a non-zero fill size for a callback execution that is guaranteed to revert once the vault enforces its withdrawal ceiling.The intended VaultV2 integration has the same problem, but the right bound is not
maxWithdraw:VaultV2.maxWithdraw()deliberately returns0because the vault cannot make a revert-free promise when gates may be installed.VaultV2.withdraw()can still revert after the clamp returns a positive value becauseexit()checkscanSendShares(onBehalf)andcanReceiveAssets(receiver), then may calldeallocateInternal()through the liquidity adapter. The Morpho Blue adapter withdraws exact assets from Morpho Blue and the MetaMorpho adapter calls the child vault'swithdraw(). Both can fail when liquidity, queues, adapter state, or the underlying vault make the requested assets unavailable. The current clamp does not inspect any of those conditions.Recommendation
Do not treat share balance as redeemable liquidity. For generic ERC4626 vaults, the callback should check the actual withdrawal bound, for example
maxWithdraw(address(this)), after the shares are transferred in and before callingwithdraw. Clamps should only advertise capacity when they have a reliable pre-transfer bound for the supported vault.For VaultV2, do not rely on
maxWithdraw(). The clamp should check that the vault exit can actually go through: the right gate permissions must be in place, and the liquidity adapter must be able to provide the requested assets.If the clamp cannot reliably tell how much is withdrawable, it should return zero for that vault type or clearly document that these fills may revert. It should not use
convertToAssets()as if share value always means withdrawable liquidity. -
L-04 Low Zero-price asset caps act unbounded Unexpected Behavior Acknowledged
Description
Midnight.take()lets makers cap an offer bymaxUnits,maxSellerAssets, ormaxBuyerAssets. Unit-capped offers advanceconsumed[maker][group]by the filled units. Asset-capped offers instead advanceconsumed[maker][group]by the computed asset amount.That distinction breaks down when the selected asset amount is zero for a nonzero unit fill.
TickLib.tickToPrice(0)returns0. For a SELL offer at that tick,sellerPriceis zero, sosellerAssetsis also zero for any positiveunits.uint256 offerPrice = TickLib.tickToPrice(offer.tick); uint256 sellerPrice = offer.buy ? offerPrice - _tradingFee : offerPrice; uint256 buyerPrice = sellerPrice + _tradingFee; uint256 buyerAssets = offer.buy ? units.mulDivDown(buyerPrice, WAD) : units.mulDivUp(buyerPrice, WAD); uint256 sellerAssets = offer.buy ? units.mulDivDown(sellerPrice, WAD) : units.mulDivUp(sellerPrice, WAD); uint256 newConsumed; if (offer.maxSellerAssets > 0) { newConsumed = consumed[offer.maker][offer.group] += sellerAssets; require(newConsumed <= offer.maxSellerAssets, ConsumedSellerAssets()); }For a SELL offer capped by
maxSellerAssets, every nonzero fill transfers obligation units while adding zero to the cap counter. The signed asset cap never becomes exhausted in unit terms. If the seller already owns credit and marks the offerreduceOnly, takers can buy that existing credit for zero seller proceeds until the seller's credit is gone. The practical bound is the seller's remaining credit, not the finite asset cap.This should be treated as a maker or integration footgun rather than arbitrary theft. The maker must authorize a zero-price asset-denominated offer, or an integration must create one on the maker's behalf. A maker who manually signs this offer has effectively agreed to a zero price. The unsafe part is that a finite asset cap may look like an exposure cap even though it cannot bound units when the selected asset leg is always zero.
Zero-price trading is also not impossible input in this codebase. Midnight's tests cover zero-price unit fills, and periphery helpers explicitly treat zero price as unbounded unit capacity. For example,
ClampLib.getOfferRemaining()returnstype(uint128).maxwhen a finite seller-asset cap has a zero seller price. Consequently, router sizing can treat the same finite asset-capped offer as having effectively unbounded remaining unit capacity.The cleanest case is a SELL offer with
tick == 0,maxSellerAssets > 0, and no unit cap. This case does not depend on buyer-side trading fee for the cap itself because SELLsellerAssetsusesofferPricedirectly. If trading fee is nonzero, the taker may still pay the protocol fee, but the seller receives zero assets andconsumedremains unchanged. In the no-fee case, both buyer and seller asset amounts are zero.For example, Alice owns
100e18units of credit on a Midnight obligation. She signs a reduce-only SELL offer withtick = 0,maxSellerAssets = 1,maxUnits = 0, and a fresh group. Bob fills60e18units.Midnight.take()emitsbuyerAssets = 0,sellerAssets = 0, andunits = 60e18. Alice's credit falls by60e18, Bob's credit rises by60e18, andconsumed[Alice][group]is still zero. Bob can then fill the same offer for another40e18units because the group cap still appears unused.Recommendation
Document that zero-price asset-denominated caps do not bound unit exposure. Maker-facing integrations should reject this configuration unless the user explicitly opts into a free unit transfer. Reject asset-denominated offers when the selected asset leg can be zero for a positive unit fill. For the main SELL case, require
offerPrice > 0wheneveroffer.buy == falseandmaxSellerAssets > 0. For BUY offers, require the relevant buyer or seller price to be positive before allowing the corresponding asset cap.The periphery should also avoid reporting unbounded unit capacity for finite zero-priced asset caps unless that behavior is deliberately part of the public API.
ClampLib.getOfferRemaining()can return0for such offers, or expose a distinct result that callers must handle explicitly. -
L-05 Low isFinalFill Never True With Non-Zero Fee Rounding Acknowledged
Description
CollateralTransferLibchecksisFinalFill = (sourceDebtBefore == repaidUnits)to decide whether to sweep all source collateral. ButrepaidUnitsis set tosellerAssets - feein the callback, which is always strictly less thansourceDebtwhenfee > 0—sellerFeeFromTickuses ceiling arithmetic, sofee ≥ 1whenever interest rate > 0. The equality can never hold, andisFinalFillis permanently false for all fee-enabled renewals.As a result, every fee-enabled renewal leaves a residual debt (≈ fee in loan token units) and a proportional share of source collateral stranded in the source obligation. At standard parameters on a 1000 ETH position this amounts to ~4.67 ETH of residual debt and ~24.8 ETH of stranded collateral. The zero-fee path is unaffected.
Recommendation
Replace the static budget comparison with a post-repay on-chain state check:
bool isFinalFill = MORPHO_MIDNIGHT.debtOf(sourceObligationId, seller) == 0; -
L-06 Low Fee recipients can get stuck shares Unexpected Behavior Resolved
Description
The contract special-cases fee recipients only in
canReceiveShares.canSendSharesandcanReceiveAssetsstill read the staticallowlistwith no equivalent exemption.When the same gate is installed as
sendSharesGate,VaultV2.accrueInterest()can mint fee shares to a management or performance fee recipient that was never manually allowlisted. Those shares arrive successfully becausecanReceiveSharesreturnstrue, but any laterredeemorwithdrawfrom that recipient reverts becauseVaultV2.exit()requirescanSendShares(onBehalf). IfreceiveAssetsGateis also set, the recipient may fail that check as well.If ownership has already been renounced, there is no recovery path inside the gate. Fees can keep accruing and diluting other holders, while the corresponding claim stays stranded behind the exit gates. This turns a convenience exemption for fee minting into a permanent liveness failure for fee realization whenever
sendSharesGateis active and fee recipients were not explicitly granted exit permissions.Recommendation
Fee recipients need an exit permission set as well as a mint permission set. Either require operators to add explicit
canSendSharesand, when relevant,canReceiveAssetspermissions for fee recipients before freezing the gate, or make the fee-recipient exemption configurable for the directions needed to redeem fees. If the intended deployment only supportsreceiveSharesGate, document that using this contract assendSharesGaterequires manual fee-recipient allowlisting. -
L-07 Low Clamps ignore active collateral cap Validation Resolved
Description
SupplyCollateralCallbackClamp.clamp()sizes aMorphoV2SupplyCollateralCallbackfill from the caller-supplied collateral amounts, the seller's token balances and allowances, current debt, and the projected health or raw max-LTV bound. It never checks whether the callback schedule would activate more collateral slots than Midnight allows for one borrower.Midnight permits obligations with up to 128 collateral parameters, but
supplyCollateral()reverts withTooManyActivatedCollaterals()once a borrower's activated bitmap would exceedMAX_COLLATERALS_PER_BORROWER(10). The supply callback callssupplyCollateral()once per nonzero scheduled slot. If the seller already has 10 active collateral slots, or if the callback schedule activates more than 10 fresh slots during a fill, the clamp can still return a positive max fill because it treats every nonzerocollateralAmounts[i]as valid projected collateral. Execution then reverts part way throughonSell()when the later slot is supplied.The same mismatch exists for V2-to-V2 borrow renewals.
MorphoV2ToV2BorrowRepay.onSell()callsCollateralTransferLib.transferCollaterals(), which withdraws matching source collateral and re-supplies it into the target obligation.V2ToV2BorrowRepayClamp.clamp()only caps by source debt and reduce-only target accounting. It does not model the union of the borrower's already active target collateral slots and the source collateral slots that would become newly active during migration. A borrower can therefore have a clamp-reported fill where the target already has 10 active slots and the migrated source collateral would activate an 11th target slot, causingsupplyCollateral()to revert.This does not let a taker bypass Midnight health or steal funds. The revert happens inside the same
Midnight.take()call after the seller callback, so the prior debt updates, token transfers, approvals, and earlier collateral supplies roll back. The impact is liveness and keeper griefing: router users can receive a positive clamp quote for an offer that cannot execute under Midnight's real collateral-activation rule, and repeatedallowRevertbatches can burn gas on these doomed actions.Recommendation
Make the clamps enforce the same activation-cap invariant before returning a nonzero fill. For
SupplyCollateralCallbackClamp, readMIDNIGHT.activatedCollaterals(data.obligationId, offer.maker), count the already active bits, then add each callback slot where the current collateral is zero and the pro-rata supply for the candidate fill would be nonzero.For
V2ToV2BorrowRepayClamp, either require source and target active-collateral compatibility before advertising a fill, or extend the clamp data so the clamp can compare the target active bitmap with the source collateral slots thatCollateralTransferLibwould migrate for the proposed fill. Return zero, or cap below the first nonzero activation, when the resulting target active slot count would exceedMAX_COLLATERALS_PER_BORROWER.At minimum, offer builders should reject supply-collateral schedules and V2-to-V2 migration tuples that can activate more than 10 target slots for the borrower. The tighter fix is to put this check in the clamp layer so router sizing matches
Midnight.supplyCollateral(). -
L-08 Low Fragmented fills skip discrete collateral Rounding Acknowledged
Description
onSellcomputes each collateral deposit independently for each fill withmulDivDown(). If a nonzero configured collateral amount is small relative toofferSellerAssets, a taker can split the offer into fills where every individualsupplyAmountrounds down to zero. The callback then skips the transfer andsupplyCollateral()call for that slot, even though the same total fill executed in one transaction would have supplied collateral.This affects seller-asset-denominated offers, not only unsupported
maxUnitsoffers. For example, a callback schedule can require one raw unit of a 0-decimal collateral token for a full100e18seller-asset fill. If the taker instead fills the offer as one hundred1e18fills, each fill computes1 * 1e18 / 100e18 = 0raw collateral units. The taker creates the same aggregate debt while none of that configured callback collateral is supplied. This can remain healthy when the seller already has enough collateral, but the final collateralization differs from the signed callback schedule.The final impact is configuration-dependent. It is most relevant for low-decimal collateral, small configured collateral amounts, loose or disabled
maxLtvand accounts with enough existing collateral for dust fills to pass Midnight's final health check. It does not directly steal funds, but it lets a taker consume an offer while leaving the maker with less callback-supplied collateral than the maker would get from an equivalent full fill.Recommendation
Do not rely on per-fill floor rounding for exact collateral schedules. Enforce a minimum fill size for every nonzero collateral slot so
amounts[i] * sellerAssets / offerSellerAssetsis nonzero whenever that slot is expected to contribute collateral.If exact aggregate collateral is required across arbitrary partial fills, add stateful residual accounting keyed by the offer or group so rounding dust carries into later fills. As a simpler operational mitigation, require meaningful
maxLtvcaps and reject low-decimal callback configurations where the configured amount is too small relative to the allowed minimum fill. -
L-09 Low Target-side netting changes renewal semantics Unexpected Behavior Acknowledged
Description
Midnight nets a user's existing opposite-side position on the target obligation before creating new credit or debt. Tenor's V2-to-V2 renewal callbacks assume the target fill opens or increases the expected target position, but
Midnight.take()first consumes any existing opposite-side position.For borrow renewals, the user is the seller on the target obligation. If the borrower already has credit on the target obligation, the fill consumes that credit before creating new target debt:
uint256 sellerCreditDecrease = UtilsLib.min(units, sellerPos.credit); uint256 sellerDebtIncrease = units - sellerCreditDecrease; sellerPos.credit -= UtilsLib.toUint128(sellerCreditDecrease); sellerPos.debt += UtilsLib.toUint128(sellerDebtIncrease);MorphoV2ToV2BorrowRepay.onSell()then repays source debt and migrates collateral as if the fill were a normal borrow renewal. Economically, the user's existing target lend was sold/netted to fund the migration.For lend renewals, the user is the buyer on the target obligation. If the lender already has target debt, the fill reduces that debt before creating new target credit.
MorphoV2ToV2LendWithdrawable.onBuy()still withdraws source credit to fund the BUY offer, so the route performs debt buyback rather than opening new target lending credit.The ratifier checks market IDs, fee config, rate, maturity, cadence, and callback data, but it does not reject nonzero target opposite-side positions.
reduceOnlydoes not protect the user here because it constrains the maker, not the taker-side renewal subject.Consequently, a validated renewal can succeed while producing a different user-visible operation from the one configured:
- borrow renewal can spend existing target credit instead of creating target debt;
- lend renewal can spend source credit to pay down target debt instead of creating target credit;
- callback events and route labels can describe a renewal while the economic result is netting/buyback.
This is not necessarily unsafe when the user explicitly wants netting, but it should not be implicit in ordinary renewal routes.
Recommendation
For standard renewals, reject execution when the user has an opposite-side position on the target obligation:
- V2-to-V2 borrow renewal: reject if
updatePositionView(targetObligation, targetId, borrower).credit != 0. - V2-to-V2 lend renewal: reject if
debtOf(targetId, lender) != 0.
If netting is intended, expose it as a separate route with explicit naming, separate user opt-in, and events that report how much target credit/debt was netted versus newly opened.
-
L-10 Low Oracle validation can fail open or freeze Oracle Acknowledged
Description
OracleWithValidationCheckcan lose its secondary-validation property in several operational modes. WhenREVERT_ON_VALIDATION_ORACLE_PRICE_FAILURE == false, any revert from the validation oracle is caught and the primary price is returned without cross-checking. This includes deliberate validation-oracle circuit breakers. The wrapper can therefore become primary-only exactly when the validation oracle is signaling failure. Graceful mode is also incomplete: Soliditytry ... returns (uint256)catches call reverts, but malformed successful return data can revert during ABI decoding and bypass the intended graceful fallback. The owner can pause validation, and only the owner can unpause. If ownership is renounced while paused, the wrapper is permanently primary-only. Conversely, in strict mode, renouncing ownership removes the only built-in recovery control for future validation-oracle failure. Consequently, depending on deployment mode, the oracle can silently run without validation, remain permanently primary-only, or become permanently unusable when the validation oracle fails.Recommendation
Use strict mode for safety-critical markets unless primary-only fallback is explicitly accepted. If graceful mode is kept, use low-level
staticcalland validate returndata length before decoding. OverriderenounceOwnership()so it cannot be called while validation is paused and document that strict immutable deployments intentionally give up emergency pause recovery. -
L-11 Low Vault executor redeems without asset floors Validation Resolved
Description
MidnightVaultExecutorexposes redeem operations without caller-supplied minimum asset amounts.withdrawCollateralAndRedeem()withdraws an exact number of vault shares from Midnight and accepts whateverredeem()returns for the receiver.onRepay()does the same while trying to fund a Midnight repayment.onLiquidate()redeems the seized shares and forwards whatever assets the vault returns to the liquidator.For a trusted stable vault this may be acceptable. As a public executor, though, the caller cannot protect against vault losses, share-price movement between transaction construction and execution, or supported vaults whose redeemable value changes because of queues, fees, donations, or adapter state. A withdrawal or liquidation can execute with materially fewer assets than the user or liquidator expected and the transaction has no local bound that can stop it.
Recommendation
Add explicit minimum asset parameters to redeeming entrypoints.
withdrawCollateralAndRedeem()should requireassets >= minAssetsOut.repayAndWithdrawCollateral()should require the redeemed amount to cover the requested repayment plus any user-specified surplus floor.liquidateAndRedeem()should let the liquidator provide a minimum redeemed amount or minimum liquidation surplus and revert when the actual redeem output is below that value. -
L-12 Low Fee adjuster overshoots maxFill Rounding Resolved
Description
CallbackFeeAdjuster.beforeDispatch()is intended to captakeUnitsso the router's effective fill infillIndexstays within the remaining budget. For SELL offers using percentage-fee adjustment withfillIndex == FILL_BUYER_ASSETS, the closed-form inverse can return a units cap that is one wei too large.The router dispatches
Midnight.take()with that cap, thenCallbackFeeAdjuster.afterDispatch()adds the percentage fee tobuyerAssets. On affected rounding boundaries, the adjusted total exceedsmaxFillby one wei andTakeRouterreverts withFillOvershoot.This does not create a value-stealing primitive because the revert unwinds the transaction. The impact is deterministic DoS of otherwise valid exact-budget routed fills, forcing keepers or offchain routers to add slack or avoid exact
FILL_BUYER_ASSETSpercentage-fee routes.Recommendation
Add a forward-check correction after the inverse calculation for percentage-fee
FILL_BUYER_ASSETSsizing: compute the actual raw buyer assets and fee for the candidate units, then decrement units untilrawBuyerAssets + fee <= remainingBudget. -
L-13 Low Griefing V1=>V2 migration Gas Griefing Acknowledged
Description
MorphoV1ToV2BorrowCallback.onSell()migrates a borrower from Morpho Blue V1 to Midnight V2 by usingsellerAssets - feeas the repayment budget for the V1 debt. During callback execution it reads the live V1 debt withMORPHO_BLUE.expectedBorrowAssets(...), then reverts if the fixed repayment budget is greater than that live debt.Because Morpho Blue allows anyone to repay on behalf of a borrower, a third party can front-run a quoted migration with a dust repay. If the migration was quoted as an exact or near-exact full migration, the dust repay lowers the live debt while the signed offer still produces the original
repayBudget. The callback then reverts withExcessRepayment, forcing the borrower or keeper to re-quote and resubmit the migration.This is not a direct value-stealing issue: the attacker pays down the borrower's debt and the migration can be re-quoted. It is nevertheless a cheap liveness grief against time-sensitive V1->V2 borrow renewals. Morpho Blue explicitly documents the underlying behavior: a small repay can front-run a repay and make it revert.
Recommendation
Avoid reverting when
repayBudgetis slightly above the live V1 debt. IfrepayBudget >= v1Debt, treat the fill as a final migration: repay bypos.borrowShares, migrate all V1 collateral, and base accounting/events on the actual live debt repaid. If surplus loan tokens can remain in the callback after the exact share repay, refund them or ensure they cannot be stranded or reused across users. -
L-14 Low Router fee metadata under-reports fees Unexpected Behavior Acknowledged
Description
TakeRouterdelegates fee sizing and post-dispatch accounting toICallbackFeeAdjusterusingactions[i].feeAdjusterData. The canonicalCallbackFeeAdjusterdecodes that data as(feeRate, FeeFormula)and uses the selected formula in bothbeforeDispatch()andafterDispatch(). Neither the router nor the adjuster checks that the selected formula matches the callback encoded in the action.For callbacks that charge a flat percentage fee, such as V2-to-V1 borrow/lend paths, a caller can label the action as
FeeFormula.INTEREST. Near par, the interest-based formula reports zero or near-zero fee, while the actual callback still chargespercentageFee(...). The router can therefore report totals and enforcemaxFillagainst a lower fee than the callback-side cost the taker experiences.This is not arbitrary third-party theft by itself: the transaction still must be submitted by the taker or by an operator that passes the router's authorization check. The protection failure is that router-level limits are only as correct as untrusted per-action metadata.
Recommendation
Do not accept the fee formula as arbitrary caller metadata. Derive the expected formula from the callback address, or validate
(callback, FeeFormula)pairs before dispatch. Revert if a percentage-fee callback is routed withFeeFormula.INTEREST, or if an interest-fee callback is routed withFeeFormula.PERCENTAGE. -
L-15 Low Bidirectional intents enable fee-draining churn Gaming Acknowledged
Description
RenewalIntentRatifierstores permissions independently per(user, callback, sourceMarketId, targetMarketId)and never consumes them on use.That lets opposite directions remain live at the same time. A realistic lifecycle is:
- the user enables
V1->V2to move from V1 fixed-rate exposure into V2 variable-rate exposure - a keeper executes that migration
- later, market conditions flip, so the user enables
V2->V1to move back into V1 - a keeper executes that exit
- the old
V1->V2intent is still live, so the keeper can immediately move the user back into V2
The protocol never enforces that:
- only one direction may be active
- executing one direction invalidates the reverse direction
- a position cannot be renewed straight back into the market it just exited
Each extra round-trip charges real fees:
V1->V2 lendwithdrawsbuyerAssets + feefrom the user's vault positionV2->V1 lenddeposits onlysellerAssets - feeback into the vault
The problem is worse on
V2->V1, whereBaseRenewalRatifierexcludes the flat callback fee from rate validation (reported I-03).In effect, the
V2->V1protocol fee is paid out of the user's ratified price budget. The keeper only needs a quote that passes the pre-fee check; the callback can then worsen the user's realised outcome by the omitted flat fee while still passing ratification.Assuming executable offer liquidity exists, including self-supplied or coordinated liquidity, stale bidirectional intents become a repeatable value-draining churn loop.
V2->V1becomes valid whenblock.timestamp == sourceMaturity - renewalWindow, and the staleV1->V2intent back into that same market is still valid becausetargetMaturity == block.timestamp + minDuration. At that boundary a keeper can immediately alternate the two directions against the same V2 market and stack fees each time.Recommendation
Invalidate stale directional intent when a migration executes. Consider:
- consuming the executed intent so it cannot be replayed later
- adding nonces or one-shot intent IDs
- requiring explicit re-authorization before a position can migrate back into the market it just exited
- the user enables
-
I-01 Informational V1->V2 borrow renewal can spend target credit Validation Acknowledged
Description
MorphoV1ToV2BorrowCallbackassumes the tradedobligationUnitsbecome new debt on the target Midnight obligation. That is not guaranteed in the actual renewal flow.RenewalIntentTakeOnBehalf.take()callsMidnight.take()with the borrower as the sell-side taker of a lenderbuyoffer. In that branch, Midnight first appliesunitsagainstsellerPos.creditbefore this callback executes:uint256 sellerCreditDecrease = UtilsLib.min(units, sellerPos.credit); uint256 sellerDebtIncrease = units - sellerCreditDecrease; uint128 buyerPendingFeeIncrease = UtilsLib.toUint128(buyerCreditIncrease.mulDivDown(_obligationState.continuousFee * timeToMaturity, WAD)); uint128 sellerPendingFeeDecrease = sellerPos.credit > 0 ? UtilsLib.toUint128(sellerPos.pendingFee.mulDivUp(sellerCreditDecrease, sellerPos.credit)) : 0; buyerPos.debt -= UtilsLib.toUint128(units - buyerCreditIncrease); buyerPos.pendingFee += buyerPendingFeeIncrease; buyerPos.credit += UtilsLib.toUint128(buyerCreditIncrease); sellerPos.pendingFee -= sellerPendingFeeDecrease; sellerPos.credit -= UtilsLib.toUint128(sellerCreditDecrease); sellerPos.debt += UtilsLib.toUint128(sellerDebtIncrease);Therefore any credit the borrower already holds on the target obligation is sold first, and only the remainder becomes new debt.
The callback does not guard against that condition. It still uses the received loan tokens to repay the V1 debt and migrates V1 collateral into the target obligation. A keeper can therefore turn a "borrow migration" into a discounted sale of the user's existing target credit whenever the user already lends in the chosen target market. The lender side receives that credit at the renewal price, while the user loses a position that was not covered by their borrow-renewal guardrails.
reduceOnlydoes not help here, since the external offer maker is the lender and the borrower is the taker.Recommendation
Reject V1-to-V2 borrow renewals when the user already has credit on the target obligation. This check needs to happen before
Midnight.take()mutates the position, since the callback only sees post-trade state and cannot detect credit that was fully consumed in the same call. The ratifier or orchestrator should readupdatePositionVieworcreditOffor the target obligation and revert if it is non-zero for this renewal type. If that behavior is intentional, document it explicitly and treat target-credit sales as a separate flow. -
I-02 Informational Stale grace timer survives healthy recovery Unexpected Behavior Acknowledged
Description
startGracePeriod()stores a single timestamp per borrower and obligation and_requireLiquidationAllowed()later checks only whether the current time still falls inside the window derived from that timestamp. The gate never invalidatesgracePeriodInfoafter the borrower returns healthy and it does not distinguish between a borrower who remained continuously unhealthy and a borrower who recovered before becoming unhealthy again.As a result, a borrower can become unhealthy, have anyone arm the grace timer, cure the position and then become unhealthy again before the original liquidation window expires. The second unhealthy episode inherits the old timer and can be liquidated immediately, even though the documentation describes a fresh
unhealthy -> startGracePeriod -> grace period -> liquidatelifecycle. This does not let liquidators seize a healthy position, because Midnight still re-checks current health at liquidation time. The bug is narrower and more precise: a borrower who recovers loses the promised grace period on the next unhealthy episode.Recommendation
Make grace-period validity track continuous unhealthy state, not just elapsed wall-clock time since some historical dip. Move the "unhealthy since" tracking into Midnight, where collateral and debt updates can invalidate stale timers whenever an account becomes healthy again. If the gate must keep the state, add an explicit invalidation path tied to every action that can restore health and require a fresh
startGracePeriod()after recovery. -
I-03 Informational V2-to-V1 fees bypass rate limits Validation Acknowledged
Description
MAX_FEE_RATE_V2_V1permits non-zero fees on V2->V1 renewals. That is unsafe becauseBaseRenewalRatifier._feeRateForRateCheck()returns zero for both V2->V1 callbacks before the ratifier builds the price checked byPriceLib.satisfiesRateLimit(). The callbacks still chargeCallbackLib.percentageFee()on principal. Consequently, the fee is not included in the user'slimitRatePerSecondprotection.An offer that sits exactly on the user's rate boundary can pass ratification and then settle worse once the fee is added or deducted. This becomes severe near maturity.
_computeDuration()usessourceMaturity - block.timestampfor V2->V1 exits, so the same 25 bps principal fee is roughly equivalent to 91% APR with 1 day remaining and about 131,400% APR with 1 minute remaining. After maturity, the rate model collapses to par because duration is zero, but the fee is still collected.Because
setFeeConfig()is owner-controlled and takes effect immediately, a malicious or compromised fee admin can raise V2->V1 fees up to this cap and extract value from pending renewals without trippingInvalidOfferRate.Recommendation
Do not include the V2-to-V1 flat percentage fee in the interest-rate check if the intended policy model is that V2-to-V1 exits are checked pre-fee. Instead, document that distinction directly in the ratifier code and deployment docs.
The documentation should make clear that, for V2-to-V1 renewals,
limitRatePerSecondconstrains only the pre-fee exchange rate. The flat exit fee is controlled separately byMAX_FEE_RATE_V2_V1, market/action fee configuration and the ratifier owner trust model. -
I-04 Informational Sell takes can spend router-held tokens Warning Acknowledged
Description
_dispatchMidnightTake()treats a sell offer withtakerCallback == address(0)as a case where the router must approve Midnight to pull loan tokens. This matchesMidnight.take()'s payer selection, but it means the payment source is the router contract itself, notparams.taker.An attacker can self-authorize the router on Midnight, set themselves as
params.taker, and submit aMIDNIGHT_TAKEagainst an attacker-controlled sell offer. If the router holds the obligation'sloanToken, Midnight pullsbuyerAssetsfrom the router, sendssellerAssetsto the maker-side receiver, and credits the purchased position to the attacker-controlled taker. The router never checks that the spent loan tokens came from the taker who passed the authorization check.Consequently any loan-token balance stranded on the public router is globally spendable through this direct sell execution. The code and tests intentionally support router-funded settlement for this case, but the public
TakeRouteris not documented as a custody component and has no per-user balance accounting or sweep function.The same inherited logic exists on
TenorAdapter, but the adapter impact is weaker.TenorAdapterinheritsCoreAdapter.erc20Transfer, so a user can appenderc20Transfer(loanToken, user, type(uint256).max)to the same Bundler3 multicall and sweep the adapter's full residual token balance. If the user fails to do that, the stale adapter balance is already publicly sweepable throughCoreAdapter.erc20Transfer, without needing this router sell execution. Therefore the adapter residual case does not justify high severity by itself.Recommendation
Do not let the public router become the payer for direct sell takes. The smallest safe fix is to reject
MIDNIGHT_TAKEactions where!action.offer.buy && d.takerCallback == address(0).If router-funded settlement is intentionally needed, gate it behind a separate trusted mode and account for the exact balance supplied for the current action. Do not fund it from the router's aggregate token balance. Adapter integrations should also append an explicit final sweep when they transfer tokens into
TenorAdapter. -
I-05 Informational Supply collateral clamp quotes unhealthy fills Warning Acknowledged
Description
SupplyCollateralCallbackClamp._maxUnitsFromDebtLimit()computes an initial maximum fill from a linearized health formula, then simulates the callback collateral for that fill. If the simulated position is unhealthy, it performs one correction:if (forwardLimit < forwardDebt) { maxUnits = forwardLimit > currentDebt ? forwardLimit - currentDebt : 0; }That correction is not self-consistent.
forwardLimitwas computed using the oldmaxUnits, but the callback collateral is proportional tosellerAssets. WhenmaxUnitsis reduced,sellerAssetscan also shrink. The callback then supplies less collateral than the value used to compute the corrected limit.Consequently the clamp can return a positive fill that still makes
Midnight.take()revert withSellerIsLiquidatable, even though a smaller fill would succeed.Recommendation
Make the health and max-LTV cap converge before returning a fill size. The simplest fix is to replace the single correction step with a monotone binary search over units, using the same seller-asset conversion and pro-rata collateral calculation that execution will use.
Alternatively, repeat the forward simulation after each correction until the returned
maxUnitssatisfiesforwardLimit >= currentDebt + maxUnits. The implementation should also keep the existing conservative rounding behavior so the clamp may return slightly less than the true maximum, but never more than an executable fill. -
I-06 Informational TENOR_VAULT_V2 clamp reads wrong data Unexpected Behavior Resolved
Description
V1ToV2LendClamp.clampalways passesoffer.callbackDatainto_ownerWithdrawable. The TENOR_VAULT_V2 branch then readsmorphoBlueMarketIdfrom that byte array with assembly.That is wrong for the normal V1-to-V2 lend renewal shape. The lender is the taker and the lend callback is passed as
takerCallback, so the realMorphoV1ToV2LendCallback.CallbackDatalives inTakeOnBehalfData.takerCallbackData.offer.callbackDatabelongs to the maker's seller callback and is commonly empty because the seller offer does not need a callback.The result is that the TENOR_VAULT_V2 clamp cannot see the market ID that the callback will use. With empty
offer.callbackData, it readsbytes32(0), queries the zero Morpho Blue market, and returns zero liquidity even when the lender has vault shares and the real Morpho Blue market is liquid. The audit testtest_tenorVaultV2ClampCannotReadTakerCallbackDataOnSellOffer()shows this directly: the clamp returns zero for the real SELL-offer shape, then returns a positive cap only after the same callback data is redundantly copied into the otherwise unusedoffer.callbackData.This blocks TENOR_VAULT_V2 clamped V1-to-V2 lend renewals unless the seller signs unused callback data solely for the clamp. If integrators fall back to the VAULT_V2 or generic ERC4626 branch to avoid the zero cap, they lose the intended Morpho Blue liquidity bound and can produce revert-only fills when the underlying market is illiquid.
Recommendation
Do not read TENOR_VAULT_V2 liquidity data from
offer.callbackDatafor taker-callback renewals. MovemorphoBlueMarketIdintoV1ToV2LendClampData, or extend the router/clamp interface so the clamp receives the actual taker callback data used byMidnight.take().The clamp should also ABI-decode the callback data or clamp data instead of using an unchecked assembly load. That makes malformed data fail clearly and allows the clamp to validate that the vault, fee rate, and market ID being capped match the callback execution.
-
I-07 Informational Vault supply clamp uses an unbound tick Unexpected Behavior Resolved
Description
VaultSupplyClamp.clampcomputes the seller top-up cap fromoffer.tick, butMorphoV2SupplyVaultSharesCallback.onSellcomputes the actual top-up fromcallbackData.tick. The interface says those ticks must match, but neither the callback nor the clamp enforces that relationship. The clamp also reads only the third ABI word fromoffer.callbackData, so it does not reject malformed or inconsistent remaining callback data before returning a cap.For sell offers, a maker can sign an offer with a lower
offer.tickand a highercallbackData.tick. The clamp then reports a fill size based on the smaller top-up. When the take executes, the callback pulls the larger top-up and can revert because the seller's balance or allowance is insufficient for the amount that the clamp did not size.Consequently, routed batch or keeper can select a fill that the clamp advertises as safe and then lose the action to a callback revert.
Recommendation
Bind the callback tick to the offer tick before returning a nonzero clamp value. The clamp should ABI-decode the full
CallbackData, require the expected length and structure and requirecallbackData.tick == offer.tick. -
I-08 Informational Vault supply clamp omits solvency bound Validation Resolved
Description
VaultSupplyClamp.clampreturns a fill cap from the seller's top-up balance and allowance only. It does not check whether the shares minted by the callback will make the seller healthy under the obligation oracle and LLTV. The comment justifies this by assuming the vault-to-loan-token oracle price only increases.That assumption does not hold for the intended VaultV2 collateral. The project documentation states that underlying bad debt can reduce VaultV2
totalAssetsand drop the share price. If the share price or oracle value decreases, or ifadditionalDepositPercentis below the current LLTV requirement, the clamp can return a positive unit amount that will always revert at Midnight's finalSellerIsLiquidatablecheck.This does not create bad debt because Midnight still enforces health after
onSell. The issue is liveness and routing reliability. A router or keeper using the clamp can select a fill that the clamp reports as available, only to revert after the callback deposits and supplies shares.Recommendation
Make solvency part of the clamp's fill bound. For vault-share collateral, cap units by the seller's post-fill health using the current oracle price, LLTV, expected share output, existing debt, existing credit and existing collateral. If the clamp intentionally avoids oracle reads, document that it is only a balance/allowance clamp and must be paired with an independent health-aware bound before routed execution.
-
I-09 Informational Grace Period Skipped Once Obligation Matures Validation Resolved
Description
DelayedLiquidationGateenforces the grace period, liquidation window, and the exclusive priority-liquidator sub-window only while the obligation is pre-maturity. Onceblock.timestampcrossesobligation.maturity, the gate's checks are skipped entirely and any liquidator can proceed — regardless of whether the grace period has elapsed or whether a priority liquidator was assigned.There is no validation at
startGracePeriodthat the configuredGRACE_PERIOD + LIQUIDATION_PERIODfits inside the remaining time to maturity. If a position becomes unhealthy close to maturity, both the borrower's grace period (intended as a self-cure window) and the priority liquidator's exclusive sub-window can collapse — partially or entirely — the moment maturity is reached.Recommendation
Either:
- Validate at
startGracePeriodthatobligation.maturity > block.timestamp + GRACE_PERIOD + LIQUIDATION_PERIOD, reverting otherwise; or - Explicitly document that the grace period and priority window are best-effort and are truncated or skipped once the obligation matures.
- Validate at
-
I-10 Informational Factory allows near-total oracle deviation Validation Acknowledged
Description
createOracleWithValidationCheck()only rejectsmaxOracleDeviation >= 1e18. Therefore the factory accepts values arbitrarily close to 100%.That bound is enough to prevent an exact 100% deviation, but it is not a meaningful safety bound for a lending oracle. With
maxOracleDeviation = 1e18 - 1, a primary price of1e36accepts a validation price of1e18. The wrapper is still factory-deployed andisDeployedOracleis true, but the validation oracle can be almost zero relative to the primary price.This matters because Midnight computes collateral debt capacity from the returned primary price. A factory-valid wrapper can therefore be configured so the secondary oracle provides almost no protection before the price is used in borrowing-limit and liquidation checks. This is a configuration-boundary issue rather than a permission bypass. It requires a market or deployment process to accept an unsafe deviation value.
Recommendation
Do not treat
maxOracleDeviation < 1e18as a sufficient risk bound. Add deployment tooling or governance checks that enforce a market-specific maximum deviation before an oracle can be used as collateral.For lending markets, derive the maximum from the collateral's LLTV and liquidation assumptions. If the current primary-denominated formula remains, use the stricter bound documented in the existing deviation finding rather than comparing
maxOracleDeviationdirectly to1 - LLTV. -
I-11 Informational Zero-owner oracle docs cannot deploy Documentation Resolved
Description
The oracle README says to set
ownertoaddress(0)to make validation immutable. That deployment cannot succeed with the current dependency tree.OracleWithValidationCheckpassesinitialOwnerinto OpenZeppelinOwnable, andOwnablereverts when the initial owner isaddress(0).Consequently the documented immutable deployment method is not available through either the factory or a direct constructor call. The actual way to remove owner powers is to deploy with a nonzero owner and then call
renounceOwnership(). That sequence has different safety requirements because the owner can pause validation before renouncing, which leaves the oracle permanently primary-only.Operators following the docs will either ship a failing transaction or use a renounce sequence without the explicit pre-renounce state checks that this contract needs.
Recommendation
Update the README to describe the real immutable deployment procedure. The safe runbook is to deploy with a nonzero owner, verify that
validationCheckPaused == false, verify that both oracles return acceptable prices, and only then renounce ownership if immutability is desired. -
I-12 Informational Lender rate floors bypassed after early repay Logical Error Acknowledged
Description
BaseRenewalRatifieris supposed to stop a permissionless keeper from renewing a lender into a bond priced below the lender's configured floor rate. ForLEND_V2_TO_V2_WITHDRAWABLE, that protection is computed with the wrong duration once the source bond has already been repaid early.The ratifier always prices V2->V2 renewals using
targetMaturity - sourceMaturity.That assumption is valid for the borrower-side renew path, where the source debt remains live until it is repaid inside the callback. It is not valid for the lender-side
withdrawablepath. Once the borrower of the source obligation repays early, the lender's capital is no longer locked untilsourceMaturity; it is immediately redeployable. The callback then pulls that cash out of the source obligation at execution time and uses it to fund the new target bond.So after an early repay, the economic tenor of the renewed lend is
targetMaturity - block.timestamp, nottargetMaturity - sourceMaturity.This direction matters because
LEND_V2_TO_V2_WITHDRAWABLEis treated asuserIsBuy = true, so the rate check enforces a lender floor. InPriceLib, a shorter duration produces a higher zero-coupon price. That makes the lender floor easier to satisfy, which lets the taker pay too much for the target bond and accept a lower yield than their configured minimum. In practice, the borrower on the other side of the target obligation receives financing at a better rate than the lender allowed, defeating the ratifier's primary purpose.This is exploitable under normal operation, for example:
- lender originally lends
100untilt + 4w - source borrower fully repays at
t + 1w - renewal executes immediately at
t + 1winto a target maturing att + 5w - ratifier duration:
t+5w - t+4w = 1w - real deployed duration:
t+5w - t+1w = 4w
At a 5% floor, the 1-week price floor is materially higher than the 4-week price floor. A new borrower can therefore borrow against that lender's liquidity at a lower yield than the lender approved, simply by waiting for early repayment to create withdrawable source funds and then matching the renewal against a target offer priced at the inflated floor.
Recommendation
For
LEND_V2_TO_V2_WITHDRAWABLE, compute duration from execution time, not from the old maturity:targetMaturity - block.timestamp - lender originally lends
-
I-13 Informational Withdrawals escape lazy bad debt Validation Acknowledged
Description
Bad debt is socialized only when
liquidate()is called and the bad-debt branch updates the obligation'slossIndex. Until that happens,withdraw()still lets any lender burn current credit and withdraw from the globalwithdrawablebucket at par.An informed lender can therefore exit before losses are realized. The lender withdraws their credit against existing
withdrawableliquidity, then the same account or another liquidator callsliquidate()on an already-bad borrower. The subsequentlossIndexupdate slashes only the lenders who still hold credit. The exiting lender avoided their share of the loss because their credit was already burned at par.This creates a bank-run dynamic: repayments are paid out first-come-first-served, while losses are recognized lazily.
DelayedLiquidationGatecan make the timing worse because it may block liquidation during a grace or priority period while withdrawals from the same obligation remain open.Recommendation
Do not allow par withdrawals to outrun known bad debt. Possible fixes include realizing pending bad debt before withdrawals, freezing or haircutting withdrawals while liquidation grace periods are active, or moving to indexed repayment/loss accounting where each credit holder's withdrawal entitlement and loss exposure are applied consistently over time.
-
I-14 Informational Renewal skips collateral missing from target Unexpected Behavior Resolved
Description
transferCollateralswalks every collateral token in the source obligation, but only transfers a token whentargetObligation.findCollateral()returnsfound. If the target obligation does not list that source token, the helper leavescollateralAmounts[i]at zero and keeps going.This is safe for a generic helper only if the caller has already opted into partial collateral migration.
MorphoV2ToV2BorrowRepay.onSelldoes not enforce that. It only checks that the source and target obligations use the sameloanTokenbefore it repays source debt and callstransferCollaterals. The ratifier binds the exact source and target market IDs, but it does not prove that the two obligations have the same collateral token set. The V2-to-V2 borrow clamp documents the opposite assumption: source and target obligations should be identical except for maturity.Consequently, a V2-to-V2 borrow renewal can repay some or all source debt, emit
PositionRenewedand silently leave source collateral behind. On a final fill, the source debt can be fully closed while unmatched collateral remains in the old obligation. On a partial fill, the borrower can end with target debt plus residual source collateral state that the surrounding renewal machinery treats as migrated.Recommendation
Enforce the renewal-specific compatibility requirement before calling
transferCollaterals. For the documented V2-to-V2 borrow renewal, require the source and target obligations to have the same collateral token set and compatible collateral parameters, not just the sameloanToken.If partial collateral migration is intentional, make it explicit. The callback should reject unapproved mismatches, emit clear residual-state information for skipped tokens and the README, clamp comments and source-closure properties should be updated to describe that behavior.
-
I-15 Informational TakeRouter returns adjusted totals only Unexpected Behavior Acknowledged
Description
TakeRoutertracks two different aggregate result sets during batch execution:rawTotals, which match the raw fill amounts that Midnight writes intoconsumed[maker][group], andtotals, which are post-adjustment values afterfeeAdjuster.afterDispatchtilts the result against the taker. This distinction is documented inline in_execute.However, the external
executeentrypoint returns onlytotals, andBatchExecutedalso emits only the adjusted totals. An integrator that assumes these returned values are equivalent to Midnight's raw fill accounting can drift fromconsumedwhen callback fee adjustment is enabled.function execute(ExecuteParams calldata params, Action[] calldata actions) external virtual returns (uint256, uint256, uint256) { (uint256[3] memory totals,) = _execute(params, actions); return (totals[0], totals[1], totals[2]); // only adjusted totals are exposed } // ... // callers reconciling against `consumed` must use `rawTotals`, not `totals` rawTotals[RouterLib.FILL_BUYER_ASSETS] += buyerAssets; rawTotals[RouterLib.FILL_SELLER_ASSETS] += sellerAssets;Recommendation
If external consumers are expected to reconcile against Midnight
consumedaccounting, exposerawTotalsin theexecutereturn values or emit them in a separate event. Otherwise, document more explicitly thatexecuteandBatchExecutedexpose taker-facing adjusted totals only and must not be used as a proxy for raw Midnight fill accounting. -
I-16 Informational V2-to-V2 lend callback self-funds fresh credit Unexpected Behavior Acknowledged
Description
MorphoV2ToV2LendWithdrawable.onBuytrustscallbackData.sourceObligationas the obligation from which the buyer already has withdrawable credit. It only checks that the source and target loan tokens match. It does not reject the case where the source obligation is the same obligation currently being bought into.This is a follow-up manifestation of
H-03: Fresh credit drains old repayments. InMidnight.take, the buyer receives fresh credit beforeonBuyexecutes and before Midnight pulls payment from the buyer callback. Therefore a rawMidnight.takeor a routerMIDNIGHT_TAKEaction can passsourceObligation == obligationto this public callback. In those paths, the taker suppliestakerCallbackData, which is decoded by this callback asCallbackData; that makessourceObligation,feeRate,feeRecipientandtickattacker-controlled for the exploit path. The callback then callsMORPHO_MIDNIGHT.withdraw(callbackData.sourceObligation, neededAssets, buyer, address(this))and burns the buyer's just-minted credit against the same obligation's oldwithdrawablepool.Because the raw-take path lets the attacker choose the callback data, the attacker can set
feeRate == WADandfeeRecipient == attacker. With those values, the callback withdraws approximately the full obligation unit amount from old withdrawable liquidity. It sends the discount component to the attacker as a callback fee and approves onlybuyerAssetsback to Midnight so settlement can complete. The attacker starts with no loan tokens, the borrower receives the discounted take price and old withdrawable liquidity is drained to fund the new borrower debt.The canonical renewal ratifier constrains
feeRateandfeeRecipientonly when the sanctioned renewal-intent path is used and theV2ToV2LendWithdrawableClamprejects or zeroes self-renewal configurations only when that optional clamp is used. Neither policy layer protects raw Midnight offers orTakeRouteractions that useActionType.MIDNIGHT_TAKE, because this callback is callable by Midnight directly with caller-provided callback data.This is repeatable but not unbounded. Each successful execution consumes the target obligation's existing
withdrawableliquidity one-for-one, so the path stops for that obligation once the old withdrawable pool is empty unless later repayments refill it. The attacker needs both old withdrawable liquidity to drain and a way to mint fresh credit in the same obligation.There are two practical cases:
- If honest borrower sell offers already exist, the attacker can take those offers with the public callback. The honest borrower receives the discounted loan proceeds, the attacker captures the discount as the callback fee, and pre-existing withdrawable lenders are left funding the new borrower debt.
- If no suitable honest borrower offer exists, the attacker can use a second address as the borrower because
Midnight.takeonly rejects exact same-address self-takes. In that case, the attacker-controlled borrower must still provide acceptable collateral and ends up with the new debt. The path can still drain old withdrawable liquidity, but profitability then depends on the borrower's collateral, liquidation/default assumptions, and the cost of creating that debt.
Recommendation
Reject same-obligation source and target use inside the callback itself. The guard should be local to
MorphoV2ToV2LendWithdrawable.onBuybecause external ratifiers, clamps and routers are optional policy layers.bytes32 targetObligationId = IdLib.toId(obligation, block.chainid, address(MORPHO_MIDNIGHT)); bytes32 sourceObligationId = IdLib.toId(callbackData.sourceObligation, block.chainid, address(MORPHO_MIDNIGHT)); if (sourceObligationId == targetObligationId) revert SelfRenewal();Also enforce any intended maturity and fee constraints in the callback when direct
Midnight.takeusage is supported. At minimum, rejectcallbackData.sourceObligation.maturity >= obligation.maturity, reject positive fees with a zero fee recipient, and keep the callback tick bound to the offer tick through the caller's data model. -
I-17 Informational Renewal grants survive Midnight revocation Trust Assumptions Acknowledged
Description
RenewalIntentTakeOnBehalf.setIsAuthorized()andRenewalIntentRatifier.setParams()accept calls from any account that is authorized by the user on Midnight at the time of the write. The resulting renewal-layer state is stored locally and is not linked to that Midnight authorization afterward.Consequently, a temporarily authorized delegate can plant a ratifier authorization and permissive renewal params, then keep that renewal surface alive after the user revokes the delegate on Midnight. The revoked delegate can no longer clean up the state it wrote, but the planted ratifier authorization still passes
RenewalIntentTakeOnBehalf.take()because execution only checksisAuthorized[intent.user][intent.ratifier].Recommendation
Do not let generic Midnight delegates create persistent renewal-layer authority unless that is explicitly part of the trust model. Require
msg.sender == onBehalfforsetParams()andsetIsAuthorized(). -
I-18 Informational V2-to-V1 borrow exits lack target health buffer Validation Acknowledged
Description
MorphoV2ToV1BorrowCallback.onBuy()only checks that the target Morpho Blue market uses the same loan token as the source obligation and that the target collateral token appears somewhere in the source obligation. It does not require the target market to use the same oracle or LLTV as the source collateral. It also does not require any target-side health buffer before creating the Morpho Blue borrow.This matters because
V2ToV1BorrowClampassumes the source and target have the same loan token, collateral token, LLTV, and oracle. The clamp does not check target health; it only caps by source debt and target market liquidity. Therefore a configured migration can pass the local callback checks while entering a target market with stricter LLTV or different oracle assumptions. The migration may revert late at Morpho Blue's binary health check or succeed with little liquidation margin under the target market.The borrower chooses or authorizes the target tuple, so this is primarily a migration safety and product-correctness issue. The code and clamp comments promise a stronger compatibility model than the callback enforces.
Recommendation
Require the selected target market to match the source collateral's oracle and LLTV unless the borrower has explicitly opted into a different target risk profile. Add a target-side health-buffer check that accounts for fees, existing target debt and the actual collateral amount moved.
-
I-19 Informational Stale Documentation Documentation Resolved
Description
The repository documentation and contain several stale or contradictory statements relative to the current implementation. None of these are standalone runtime vulnerabilities, but they can mislead auditors, integrators, and offchain tooling that use the docs as the source of truth:
DIMENSIONAL_UNITS.md:13says higher tick means lower price / higher discount. In reality,TickLib.tickToPrice()is monotone increasing, so higher tick means higher price / lower discount.DIMENSIONAL_UNITS.md:17states the price domain is(0, WAD]. In reality,tickToPrice(0) == 0, so the implementation domain includes zero:[0, WAD].DIMENSIONAL_UNITS.md:48says tick prices round to1e13multiples. In reality,tickToPrice()rounds with.divHalfDownUnchecked(5e12) * 5e12.PROPERTIES.md:34refers toMAX_TICK (990). In reality,MAX_TICK = 1046.PROPERTIES.md:39saystickToPrice(0) > 0. In reality, tests asserttickToPrice(0) == 0.docs/audit-checklist.md:59saystick 990 = near par. In reality, par is reached attickToPrice(1046) == 1e18.docs/audit-checklist.md:117says the effective-price fee model uses all rounding down on both seller and buyer sides. In reality,CallbackLibuses mixed rounding: seller-side effective price / budget usesmulDivUp, while buyer-side effective price / budget usesmulDivDown.CallbackData.feeRatein V2 to V1 interfaces:Fee rate on sellerAssets in WAD (max 1%, e.g., 0.01e18 = 1%)but declared constantMAX_FEE_RATE_V2_V1 = 0.0025e18- Several callback interfaces say
feeRecipient address(0) = no fee; runtime callbacks do not usefeeRecipientas the no-fee sentinel. Fees are disabled byfeeRate == 0or by a computed zero fee. - IMorphoV2SupplyVaultSharesCallback first states the vault top-up is
ceil(sellerAssets * additionalDepositPercent / WAD), but the implementation computesceil(obligationUnits * ceil(tickToPrice(tick) * additionalDepositPercent / WAD) / WAD)
Recommendation
Fix the documentation contradictions.
-
I-20 Informational Sentinel silently no-ops takes Logical Error Resolved
Description
TakeRouterAdapterBase._executeResolvingSentinelsoverloadstype(uint256).maxonExecuteParams.maxFill/minFillas a request to derive the cap from on-chain state. ForfillIndex == FILL_OBLIGATION_UNITS,_resolveSentinelreturnsdebtOf(id, taker), falling through tocreditAfterUpdateif debt is zero, and returning0if neither exists. The intended semantic is "fill up to my existing position size" — meaningful only for renewal or close-out flows where the taker already has a position in the obligation.The
type(uint256).maxvalue, however, is the canonical ERC-20 idiom for "unlimited / no cap." An integrator unfamiliar with the renewal-only semantic — or wired by a frontend reusing the ERC-20 convention — passesmaxFill = type(uint256).maxfor an opening take (no prior debt or credit)._resolveSentinelreturns0, and_execute's loop guardtotals[fillIndex] >= maxFillfires0 >= 0on the first iteration, breaking before any action runs. With the defaultminFill = 0, the post-loop floor checktotals[fillIndex] < minFilldoes not revert, soBatchExecutedis emitted with all-zero amounts. The caller observes a successful transaction that filled nothing, with valid signatures, valid approvals, and no error to diagnose.Recommendation
Either (a) revert with a named error (e.g.
SentinelResolvedToZero/SentinelRequiresExistingPosition) when_resolveSentinelreturns0and the caller passedtype(uint256).maxfor the corresponding bound — opening takes that misuse the sentinel then fail loudly instead of silently; or (b) document at theExecuteParamssite thatmaxFill = type(uint256).maxis a renewal/close-out helper, not an "unlimited" sentinel, and direct opening flows to pass concrete values. Option (a) also subsumes theFILL_BUYER_ASSETSBUY-offer path in Guardian69eb590awith a single guard. -
I-21 Informational Stable max sentinel nets gas Unexpected Behavior Resolved
Description
On Stable, USDT0 is both the native gas token and an ERC20 token. Gas is precharged from the transaction sender before EVM execution, then refunded after execution. User-called Bundler3 routes that use
type(uint256).maxas a "use my full balance" sentinel therefore resolve against the user's post-gas-reservation USDT0 balance, not the pre-submit wallet balance.This affects TenorAdapter routes that first transfer the user's max available USDT0 into the adapter, then rely on adapter-balance sentinels or max-balance actions such as
FILL_BUYER_ASSETS,midnightRepay(..., assets = type(uint256).max), ormidnightSupplyCollateral(..., assets = type(uint256).max).Depending on the route's minimum-fill and exact-amount requirements, the transaction can either succeed with a smaller fill than the user's pre-submit full-balance expectation, or revert because the post-precharge balance is insufficient for the exact downstream action.
Recommendation
For Stable deployments, document that max-balance sentinels mean "post-gas-reservation balance." Frontends and keepers should leave USDT0 gas headroom or use explicit fixed
maxFill/minFillvalues when exact fills are required. -
I-22 Informational Delayed gate blocks gated vault liquidations DoS Acknowledged
Description
MidnightVaultExecutor.liquidateAndRedeem()cannot liquidate an obligation that usesDelayedLiquidationGateas itsliquidatorGate. This is not just a missing deployment allowlist entry. The executor and the delayed gate use incompatible liquidation call shapes.The executor calls
MORPHO_MIDNIGHT.liquidate()directly, so Midnight checksDelayedLiquidationGate.canLiquidate(address(executor)). The delayed gate only returns true foraddress(this), so the executor reverts withLiquidatorGatedFromLiquidating.The alternative is also broken for share-gated VaultV2 collateral. Liquidators are expected to call
DelayedLiquidationGate.liquidate()directly, but Midnight transfers seized collateral tomsg.sender, which is then the delayed gate. If the collateral token is a VaultV2 share withreceiveSharesGateset, that transfer requiresDelayedLiquidationGateto be allowed bycanReceiveShares. The documented share-gated setup allowlistsMidnightVaultExecutor, Midnight and the vault-share callbacks, but notDelayedLiquidationGate.Consequently, the executor cannot pass the delayed liquidation gate, while the delayed gate cannot receive the seized shares. Simply allowlisting
DelayedLiquidationGateonly fixes the second revert. It does not makeMidnightVaultExecutor.liquidateAndRedeem()usable with delayed-gated obligations and it leaves the delayed gate holding raw VaultV2 shares that its currentonLiquidate()logic tries to forward to the external liquidator. If that liquidator is not allowlisted to receive shares, the forward transfer can still revert. If arbitrary liquidators are allowlisted, the share-gating design is weakened because protected shares can leave the curated set.This affects an intended protocol configuration: VaultV2 share collateral is documented as supported behind
VaultV2AllowlistGate,liquidateAndRedeem()is documented as the liquidation mechanism that gives liquidators underlying assets instead of shares andDelayedLiquidationGateis documented as a generalliquidatorGateand liquidation router for Midnight obligations. If these components are wired together, underwater or post-maturity vault-share positions can become unliquidatable and bad debt can remain unresolved.Recommendation
Make the delayed liquidation and vault-share redemption components compatible instead of relying on deployment guidance alone.
One safe design is to route vault-share liquidations through the delayed gate with the executor as the gate-level liquidator. The executor should pass callback data that lets
DelayedLiquidationGate.onLiquidate()forward seized shares to the executor, thenMidnightVaultExecutor.onLiquidate()can redeem those shares and collect repayment from the original liquidator. This still requiresDelayedLiquidationGateandMidnightVaultExecutorto be allowed by the VaultV2receiveSharesGate, because Midnight first transfers seized shares to the gate and the gate then transfers them to the executor.Alternatively, fold vault-share redemption directly into
DelayedLiquidationGateor deploy a dedicated delayed vault liquidation router. In either design, the gate should not forward raw gated vault shares to arbitrary liquidators. It should redeem shares into underlying assets before delivery or forward shares only to an allowlisted redemption contract.
Remediation Review
14 findings · May 20 to 22, 2026-
L-01 Low executeAndConsume does not bind group dimension Validation Acknowledged
Description
executeAndConsume()advances the caller'sconsumeGroupbyrawTotals[params.fillIndex]. Midnight offer groups do not carry denomination metadata. The sameconsumed[user][group]counter can represent units, seller assets or buyer assets depending on whether the signed offer usedmaxUnits,maxSellerAssets, ormaxBuyerAssets. Midnight'stake()path enforces this distinction from the offer fields, butexecuteAndConsume()only receives abytes32 consumeGroupand the router'sfillIndex.As a result, the adapter cannot verify that the amount it writes into
consumeGroupis denominated the same way as the standing offer the caller meant to consume or cancel. If an integration usesexecuteAndConsume()with a unit-capped standing offer but choosesfillIndex = FILL_BUYER_ASSETSfor the routed fill, the adapter writes buyer assets into a counter that the standing offer later compares against units. The reverse mismatch can also over-consume the group and cancel more capacity than intended.This does not let an arbitrary caller modify another user's group because
setConsumed()still requires the adapter to be authorized for the initiator. The risk is at the adapter API boundary: a frontend, solver, or SDK can produce a valid authorized bundle that leaves phantom standing-offer capacity or burns too much capacity because the route limit dimension and the standing-offer group dimension are conflated.Recommendation
Bind the group denomination explicitly. For example, add an expected consume dimension to
executeAndConsume()and require it to matchparams.fillIndex, or split the API into denomination-specific helpers so a unit group, seller-asset group and buyer-asset group cannot be advanced with the wrong total.If the adapter intentionally leaves this responsibility to offchain tooling, document that
consumeGroupmust only be used with afillIndexmatching the active max field of every standing offer in that group and add SDK/front-end validation for that invariant. -
L-02 Low Unbound collateral supply denominator Validation Acknowledged
Description
MorphoV2SupplyCollateralCallback.onSelldecodesofferSellerAssetsfrom arbitrary callback data and uses it as the denominator for every pro-rata collateral pull:CallbackData memory callbackData = abi.decode(data, (CallbackData)); ... uint256 supplyAmount = configAmount.mulDivDown(sellerAssets, callbackData.offerSellerAssets);The interface documents that
offerSellerAssetsmust equaloffer.maxSellerAssets, but this callback never receives the offer and cannot enforce that relationship. If the offer builder, router or integration signs callback data with a denominator that does not match the offer's actual seller-asset capacity, the callback supplies collateral according to the mismatched denominator instead of the amount implied by the offer. For example, a unit-denominated or price-discounted borrow offer can settlesellerAssetsbelowobligationUnits. If the callback data is populated from the unit capacity rather than the seller-asset capacity, each fill supplies less collateral than a full-offer simulation usingamounts[]would suggest.Midnight's final seller health check still prevents immediately liquidatable debt and a nonzero
maxLtvadds a stricter post-fill guard. The remaining risk is an integration hazard: users or routers can rely on an unenforced denominator and produce fills with less posted collateral, less liquidation margin or revert-only execution paths than expected.Recommendation
Only use this callback with seller-asset-denominated offers where
offer.maxSellerAssetsis nonzero and exactly equalscallbackData.offerSellerAssets. Enforce that invariant in the router, clamp, ratifier or offer-construction layer before a fill is submitted.If the callback is intended to support other offer denominations, include an explicit denomination mode or expected offer hash in the signed callback data and reject mismatches before using the pro-rata amount.
-
L-03 Low Renewal window can underflow before validation Validation Resolved
Description
BaseRenewalRatifier._ratifyWindow()subtractsp.renewalWindowfromsourceMaturitybefore checking that the renewal window is not larger than the source maturity.renewalPeriodStart = sourceMaturity - p.renewalWindow;If a user configures
renewalWindow > sourceMaturity, ratification reverts with Solidity panic0x11instead of the ratifier's domain-specificInvalidRenewalParams()error. This is reachable becauseRenewalIntentRatifier.setParams()stores user renewal params without validating this relationship.Recommendation
Validate the configured renewal window before subtracting it from
sourceMaturity.if (p.renewalWindow > sourceMaturity) revert InvalidRenewalParams(); renewalPeriodStart = sourceMaturity - p.renewalWindow; -
L-04 Low Optional router fee adjustment Validation Acknowledged
Description
TakeRouter._executeonly adjustsbuyerAssetsandsellerAssetsfor callback-level fees when the caller supplies a nonzerofeeAdjuster. Without that resolver, the router records the raw amounts returned by Midnight and then enforcesmaxFill,minFilland price bounds against those raw amounts.Fee-charging renewal callbacks can still transfer part of those assets to
feeRecipient. Consequently, a route can satisfyminFillon the router's gross accounting while the taker's net amount after the callback fee is lower. The same mismatch can makemaxFilland aggregate price checks weaker than intended for callbacks with nonzero fees.This does not bypass the renewal ratifier's rate check and the canonical
CallbackFeeAdjustercan make the accounting conservative when it is configured correctly. The risk is an integration failure: frontends, keepers or direct callers that omit the matching adjuster receive weaker router-level fill protection than the API shape suggests.Recommendation
Require a matching fee adjuster for action types and callbacks that can charge nonzero fees or derive the expected fee configuration from callback data and apply the adjustment inside the router. If the router intentionally accepts raw accounting, rename or document
minFill,maxFilland price bounds as raw-Midnight limits rather than net-taker limits. -
L-05 Low Router limits assume homogeneous actions Validation Resolved
Description
TakeRouter._executesumsbuyerAssets,sellerAssetsandobligationUnitsacross every successful action in the batch. It then enforces onemaxFill, oneminFilland one average price check against those aggregate totals.The router does not require the actions to use the same loan token, obligation, maturity, offer side, callback or renewal user. Therefore the batch-level limits are only meaningful when the caller or offchain solver keeps the batch homogeneous. If unrelated actions are mixed, a favorable fill can offset an unfavorable fill and make the aggregate price pass even though one action would not satisfy the same price bound on its own.
This is mainly an integration and user-protection issue.
TAKE_ON_BEHALFactions still pass through each user's ratifier andMIDNIGHT_TAKEacts as the initiating taker. However, if a frontend or solver presents the router's aggregatemaxFill,minFillor price bounds as per-action protection, users can receive weaker protection than expected.TakeRouterAdapterBase._resolveSentinelhas the same assumption. It resolvestype(uint256).maxfromactions[0]only, then applies the resolved cap to the whole batch.Recommendation
Either enforce homogeneous batches whenever aggregate limits are used or expose per-action bounds so each action can be checked independently.
At minimum, compare every action against the first action's loan token, obligation id and offer side before applying a shared fill cap or shared price bound. If mixed batches are intended, document that router totals are batch-level accounting only and must not be treated as per-action slippage protection.
-
L-06 Low Clamp data not bound to callback data Validation Acknowledged
Description
SupplyCollateralCallbackClampdocuments (line 29) thatclampData.collateralAmountsmust equaloffer.callbackData.amounts, but never enforces it on-chain.TakeRouter._capTakeUnitspasses both blobs independently to the clamp and the callback.If the keeper passes a
clampDatawith inflated amounts,clamp()returns an inflatedmaxUnits, the take proceeds, and the callback supplies the lower real amount — leaving the seller with debt exceeding the supplied collateral. The only on-chain catch is the callback's optionalmaxLtvcheck (MorphoV2SupplyCollateralCallback.onSell), which defaults to 0 (unconstrained) whencallbackData.length < 160.The harmed party is the taker (lender) who ends up holding undercollateralized debt — exposing them to the keeper they authorized.
Recommendation
Document that callers MUST pass
clampData.collateralAmountsidentical to theamountsfield encoded inoffer.callbackData, and that withmaxLtv = 0the only safety net is keeper honesty.Optionally: have the clamp
abi.decodeoffer.callbackDataand assert array equality before computing bounds. This eliminates the trust assumption entirely. -
L-07 Low Renewal can rescue a liquidatable obligation Trust Assumptions Acknowledged
Description
Midnight's
take()does not check source-obligation health. A renewal whose source is already liquidatable will still execute, atomically repaying source debt and migrating collateral to the target — effectively dodging liquidation. This is inherent to Midnight (the same is achievable via flash loans or hand-rolled callbacks); Tenor's renewal paths just expose it as a packaged flow.Impact: borrowers (or anyone they authorize) can use renewal to avoid the liquidation incentive penalty. Risk migrates to whichever target obligation the renewal intent points at.
Recommendation
Document explicitly that renewal paths do not block liquidatable sources — they are not "rescue-safe" from the protocol's perspective, and borrowers configuring a more lenient target market can use renewal to bypass liquidation.
-
I-01 Informational Effective units panic on long maturities Validation Resolved
Description
_effectiveUnitsPerWad()subtracts the target obligation's continuous-fee discount fromWADwithout checking that the product is belowWAD.uint256 cf = MORPHO_MIDNIGHT.continuousFee(obligationId); if (cf == 0) return WAD; uint256 t = UtilsLib.zeroFloorSub(offer.obligation.maturity, block.timestamp); return WAD - cf * t;Midnight bounds
continuousFee, buttcomes from the target obligation maturity. At the maximum Midnight continuous fee,cf * treachesWADat roughly 100 years.UserRenewalParams.maxDurationis auint32, so a user can authorize a target duration above that threshold. The ratifier's target-maturity check only compares the maturity against[block.timestamp + minDuration, block.timestamp + maxDuration]. It does not reject maturities wherecontinuousFee * timeToMaturity > WAD.Consequently, a lend renewal into a very long target maturity with a nonzero continuous fee can revert with Solidity arithmetic panic
0x11during ratification. Midnight would later reject credit creation when pending fees exceed credit, but the ratifier should fail with an intentional validation error instead of an unchecked arithmetic panic.Recommendation
Reject target maturities whose continuous-fee product is too large before subtracting from
WAD.uint256 fee = cf * t; if (fee > WAD) revert InvalidTargetMaturity(); return WAD - fee;Use
>= WADinstead if zero effective units should also be treated as invalid. This is preferable to silently saturating to zero because the underlying Midnight trade is not useful once continuous fees consume all target credit. -
I-02 Informational Open liquidator choice delays liquidations Warning Acknowledged
Description
startGracePeriod()can be called by any account once a position is unhealthy and not liquidation-locked. The caller supplies_priorityLiquidatorand the gate stores that address without checking that it is authorized, borrower-approved, or derived from a deterministic rule._requireLiquidationAllowed()later enforces the stored address during the firstPRIORITY_PERIODafter the grace period. While that exclusive window is active, every other liquidator reverts withLiquidationNotAllowed.Consequently, a bot watching unhealthy positions can front-run the first grace-period transaction and assign itself as the priority liquidator. If it does not liquidate, other liquidators must wait until the priority period expires. Factory-created gates currently limit this exclusive window to one minute, so the impact is limited in the intended factory setup. Direct
DelayedLiquidationGatedeployments can choose a longerPRIORITY_PERIOD, making the delay materially worse.Recommendation
Do not let arbitrary callers choose the priority liquidator for a permissionless grace-period start. Either remove priority assignment from
startGracePeriod(), require an authorized market role to set a nonzero priority liquidator, or derive the priority liquidator from a deterministic market configuration.Also enforce the intended maximum
PRIORITY_PERIODin theDelayedLiquidationGateconstructor, not only in the factory, so direct deployments cannot bypass the deployment-time cap. -
I-03 Informational Rate limits ignore settlement rounding Warning Acknowledged
Description
BaseRenewalRatifier._ratifyRatechecks the user's rate limit against a normalized per-WAD price. It passeseffUnitsPerWadandeffPriceintoPriceLib.satisfiesRateLimit, so the check does not know the actualtakeUnitsvalue or the integer asset amounts thatMidnight.takewill settle.Midnight.takelater computes the realbuyerAssetsandsellerAssetswith mixed integer rounding. For small fills, the rounded settlement price can be materially worse than the normalized price approved by the ratifier. For example, if the theoretical buyer price is0.5e18and the fill is1unit,mulDivUpcharges1raw asset unit, so the realized price is1e18.This is usually dust for 18-decimal assets. The gap is more relevant for low-decimal loan tokens and repeated tiny fills. In those cases a keeper can execute fills that satisfy the continuous price check while the user receives a worse realized integer price.
UserRenewalParamsalso has no minimum fill or minimum asset constraint to let the user reject these rounded executions at the ratifier level.Recommendation
Add a user-configurable minimum fill size or minimum realized asset amount to the renewal policy, then reject takes below that threshold before calling
Midnight.take.Alternatively, add a post-take validation step in
RenewalIntentTakeOnBehalf.takethat checks the returnedbuyerAssets,sellerAssetsandobligationUnitsagainst the user's realized price limits. The validation should use the same rounded values that were actually settled. -
I-04 Informational Standing renewals lack amount bounds Warning Acknowledged
Description
UserRenewalParamsrecords rate, timing, cadence and market constraints. It does not include a maximum asset amount, a maximum unit amount, a one-shot nonce or cumulative consumption for the renewal user.RenewalIntentRatifierthen reuses those params for every take matching(user, callback, sourceMarketId, targetMarketId).BaseRenewalRatifiervalidates the callback tuple, fee config, renewal window, target maturity and rate policy, but it does not validate the size of the take.For V1 to V2 lend migrations, the take size directly controls how much
MorphoV1ToV2LendCallbackwithdraws from the user's ERC4626 source vault:IERC4626(callbackData.vault).withdraw(buyerAssets + fee, address(this), buyer);After the user has approved this callback for vault shares, authorized
RenewalIntentTakeOnBehalf, authorized the ratifier and set params for the V1 to V2 tuple, any keeper can execute as much migration as offer liquidity, vault withdrawability and the callback approval allow. The user still receives target V2 credit at a rate accepted by their policy, so this is not direct theft. It can still surprise users or integrations that treat standing renewal params as consent for a bounded migration amount. A keeper can force the user's whole approved V1 vault position into V2 and charge the configured fees.Recommendation
Add user-controlled amount bounds to the renewal authorization. At minimum, include a max asset or max unit amount in the params used for each
(user, callback, sourceMarketId, targetMarketId)tuple and enforce it during ratification.If standing automation is meant to be reusable, track cumulative consumption under
intent.userand reset it by nonce, epoch or cadence period. If a renewal should be one-shot, include an expiry or nonce and consume it before external callbacks can run. -
I-05 Informational Vault withdraw callback allows position crossing Warning Acknowledged
Description
MorphoV2WithdrawVaultSharesCallback.onBuy()does not check whether the BUY fill crosses the buyer from debt into credit. The callback withdraws vault-share collateral, redeems the shares forbuyerAssets, approves Midnight and returns success without checking the buyer's post-fill position.Midnight allows this crossing when the signed offer is not
reduceOnly. That means a stale or oversized vault-withdraw BUY offer can repay the borrower's remaining debt, redeem collateral and leave the borrower with a new credit position on the same obligation.MorphoV2ToV1BorrowCallback.onBuy()rejects the same condition withPositionCrossing, but this callback does not.This may be economically valid for some users, but it contradicts the borrower early-exit intent of this callback. It also makes stale offers more dangerous: if the borrower's live debt falls below the signed fill size before execution, the callback can still complete and convert the excess into credit instead of reverting.
Recommendation
Add the same post-fill crossing guard used by
MorphoV2ToV1BorrowCallback. After approving Midnight, callupdatePositionView()for the buyer and revert withCallbackLib.PositionCrossing()if the resulting credit is nonzero.Alternatively, require all integrations that use this callback to cap take units to the buyer's current debt before calling Midnight. The safer default is to enforce the invariant inside the callback so direct Midnight fills and unclamped router calls receive the same protection.
-
I-06 Informational Stale MAX_FEE_RATE_V2_V1 in docs Documentation Resolved
Description
DIMENSIONAL_UNITS.mddocumentsMAX_FEE_RATE_V2_V1 = 0.0025e18(0.25% with units D18{loan-token/borrow-token/sec}). The actual constant insrc/libraries/Constants.solis0, meaning no fee component is permitted on the V2→V1 renewal direction.Integrators reading the doc to size off-chain rate calculations will use a fee assumption the contract rejects, producing renewal attempts that always revert at
BaseRenewalRatifier._ratifyRate.Recommendation
Update
DIMENSIONAL_UNITS.mdsoMAX_FEE_RATE_V2_V1 = 0matches the on-chain constant. Optionally add a one-line note explaining why V2→V1 has a hard-zero fee while V2→V2 and V1→V2 have non-zero ceilings. -
I-07 Informational allowRevert scope is incomplete Documentation Acknowledged
Description
Action.allowRevert = trueonly catches reverts inside the inner_MIDNIGHT.take/_TAKE_ON_BEHALF.taketry/catch. The following per-action paths run outside thetry/catchand abort the entire batch despiteallowRevert:abi.decode(action.data, ...)— malformed action data (L:264, 297)._MIDNIGHT.touchObligation(action.offer.obligation)— invalid obligation (L:266, 299).ICallbackFeeAdjuster.beforeDispatch(...)— arbitrary external call (L:347).RouterLib.budgetToUnits(...)— arithmetic (L:349).ClampLib.getOfferRemaining(...)— arithmetic (L:355).ITakeClamp.clamp(...)— arbitrary external call (L:358).ICallbackFeeAdjuster.afterDispatch(...)— arbitrary external call, runs after a successful dispatch (L:206).
Integrators relying on
allowRevert = truefor batch resiliency may not realize that these paths still abort the whole batch.Recommendation
Update the
Action.allowRevertnatspec and the file-level comment block to enumerate the pre-dispatch and post-dispatch revert paths that bypass it. For the external calls and decode paths, consider wrapping them in their owntry/catchsoallowRevertactually covers them.
No findings match.
More from Tenor
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.