Summary
Confirmed Low finding in the Cantina Morpho Midnight public competition (finding #652, morpho-org/midnight @ 7538c438513622721e23a94676b93a335b83dace).
MidnightBundles.buyWithAssetsTargetAndWithdrawCollateral() documents that “The taker will gain at least minUnits.” However, the periphery contract accumulates the raw unitsToTake returned by Midnight.take() and checks require(filledUnits >= minUnits) without syncing the taker’s position against the market’s post-callback lossFactor.
During a sell-offer fill, Midnight.take() credits the buyer first and then invokes the seller callback before returning. A sell-offer maker can use that callback to liquidate a different unhealthy borrower in the same market, raising lossFactor inside the same transaction. The bundle still counts the pre-slash unitsToTake and passes filledUnits >= minUnits, leaving the taker with a fraction of their specified minimum output once updatePosition() syncs their balance.
Affected code
src/periphery/MidnightBundles.sol:178: NatSpec states “The taker will gain at leastminUnits.”src/periphery/MidnightBundles.sol:208-225: computesunitsToTake, adds that raw value tofilledUnits, and checksfilledUnits >= minUnits.src/Midnight.sol:408-418: credits the buyer’s position and updates market units before invoking callbacks.src/Midnight.sol:458-476: executes the seller callback beforetake()returns toMidnightBundles.src/Midnight.sol:626-640: increases the market’slossFactorwhenliquidate()realizes bad debt on another borrower.src/Midnight.sol:797-819: slashes the buyer’s credited units by the updatedlossFactorwhenupdatePosition()runs.
Because the seller liquidation lock only prevents liquidating the seller during take(), the seller callback is free to liquidate a separate underwater borrower in the same market.
Proof of concept
Running BundlesMinUnitsSlashedBySellerCallbackPoC.t.sol with Foundry:
forge test --match-contract BundlesMinUnitsSlashedBySellerCallbackPoC -vv
Observed output:
[PASS] test_bundleMinUnitsPassesBeforeFreshCreditIsSlashedBySellerCallback() (gas: 1266731)
Logs:
lender paid loan tokens: 100000000000000000000
bundle minUnits argument: 100000000000000000000
raw lender credit after bundle: 100000000000000000000
post-update lender credit: 30600649350649350667
market lossFactor: 236153753017372066706982153190265421615
The taker pays 100e18 loan tokens and sets minUnits = 100e18. The bundle call succeeds without reverting, but after updatePosition() the taker holds only 30.60e18 units (a 69.4% reduction below minUnits).
Recommended fix
Sync the taker’s position after the loop in buyWithAssetsTargetAndWithdrawCollateral() and verify that the net post-sync credit delta satisfies minUnits (or revert if lossFactor increases during the bundle execution).