// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {FlashEngine} from "./lib/FlashEngine.sol"; import {LoanHealth} from "./lib/LoanHealth.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {curationOf} from "../AdapterCuration.sol"; import {IMorpho, IMorphoOracle} from "./MorphoAdapter.sol"; interface IERC20Min { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address who) external view returns (uint256); } /// Morpho Blue's consent primitive, and the ONE call this contract cannot make /// for the owner. /// /// `setAuthorization` is the owner's; `isAuthorized` is what this reads before /// it moves anything. Recovered from the singleton's dispatch set and confirmed /// live: `isAuthorized` answers false for an unrelated pair. interface IMorphoAuth { function isAuthorized(address authorizer, address authorized) external view returns (bool); } /// Morpho's permissionless accrual, so the owner's debt is read at this block /// before the migration and not at the market's last update. interface IMorphoAccrue { function accrueInterest(IMorpho.MarketParams calldata m) external; } /// @title OwnerMigrateMorphoAdapter — the OWNER moves their own Morpho position out. /// /// The Morpho arm of OwnerMigrateAaveV3Adapter. Read that contract's header /// first: the trust argument is identical and is not restated here. No /// `_consume`, the account is the only permitted caller, every beneficiary is /// `ownerOf(account)`, parameters rather than calldata, and no `to` anywhere. /// /// # What differs from the v3 arm, and each difference costs something /// /// THE MARKET COMES FROM THE PROTOCOL'S CURATION, NOT FROM THIS CONTRACT. A /// Morpho market is the hash of five parameters and a market IS its oracle, so /// the agent may not supply them and neither may the owner — AdapterCuration /// holds the one curated table per chain, and this reads it the way LoanShift, /// LoanClose and the leverage arms do. Giving this contract its own `curate` /// would mean a second table that could disagree with the first. /// /// IT READS AS AN EXIT. Migrating a position out is the wind-down, so a market /// the guardian has retired still migrates; the account's pinned version is /// the market its position is actually in. (This used to depend on the account /// still having MorphoAdapter installed; the table is chain-global now, so an /// owner can uninstall first and migrate after.) /// /// THE CONSENT IS BLANKET, AND THAT IS WORSE THAN AAVE'S. Aave delegates a /// NAMED AMOUNT from one user to one contract. Morpho's `setAuthorization` /// authorises one address over the owner's ENTIRE Morpho position, in every /// market, with no ceiling — that is what the protocol offers and there is no /// narrower form of it. /// /// THE AUTHORISED ADDRESS IS THE ACCOUNT, NOT THIS CONTRACT — finding 11 of the /// 2026-09-26 audit. It used to be this contract, which is shared: the only tie /// between an account and "its" owner is `ownerOf`, and for a passkey root that /// is an owner OF RECORD whoever creates the account writes. An attacker's /// account naming a victim could move its own debt onto the victim through the /// victim's authorisation, with one wei of collateral. The owner-side borrow now /// runs AS THE ACCOUNT, so Morpho asks `isAuthorized(owner, account)`, which a /// lookalike account does not have. Held by the account it is spendable by this /// borrow and nothing else: every other Morpho call in contracts/src fixes /// `onBehalf` to the account itself. And the owner's position in the market may /// not end more levered than it began, independent of the consent. Two /// consequences are written into this contract rather than left to a reader: /// /// 1. `AuthorizationRequired` names the exact call AND says it should be /// revoked afterwards. An owner who leaves it standing has left a /// standing authority over every Morpho position they will ever hold. /// 2. The ONLY use of that authority is `borrow(...onBehalf: owner...)` in /// `_reopenForOwner`, run as the account, on the market the account's own /// position was in. There is no other call site and no parameter that /// could redirect it. /// /// THE FLASH LENDER IS THE CONTRACT BEING REPAID. Morpho lends the flash and /// holds the position, which sounds reentrant and is not: `flashLoan` is a plain /// external call with no market lock, and LoanCloseAdapter and /// DeleverageMorphoAdapter already do exactly this. FlashEngine's own /// `ReentrantFlash` guard is what stops a second flash nesting. contract OwnerMigrateMorphoAdapter is FlashEngine { /// Arming, for the reason BaseAdapter reserves it: this reaches outside the /// account. READ `disarmed`, NEVER `allows` — the class is ON by default and /// a transaction builder that emits an unconditional arm call is asking for /// a signature the owner does not owe. See the v3 arm for the full note. bytes32 public constant OP_MIGRATE = "migrate_to_owner"; /// What to move. No `to`, no calldata, no route. struct Migrate { address account; /// The Morpho singleton, by venue NAME — flash lender and position /// holder at once. bytes32 venue; /// The market, by its curated name in AdapterCuration. bytes32 market; /// How much collateral to move, capped at what the position holds. uint256 collateralAmount; } /// One pointer instead of seven words. via_ir is pinned off because /// deterministic addresses depend on byte-identical output, and the /// migration in one body runs the stack out — the same wall the v3 arm hit. struct Ctx { Migrate m; IMorpho.MarketParams p; address owner; address morpho; uint256 assets; uint256 got; /// This contract's collateral balance before any of it passed through — /// what the end-of-call check measures a RISE against (finding 9). uint256 heldBefore; /// The owner's position in this market before: collateral and debt, in /// their own units — what the no-more-levered check compares (11). uint256 ownerCollBefore; uint256 ownerDebtBefore; } event MigratedToOwner( address indexed account, address indexed owner, bytes32 indexed market, uint256 collateralAmount, uint256 debt ); error NotTheAccount(address caller); error NothingToMigrate(); /// The owner has not authorised THE ACCOUNT on Morpho. Carries the /// singleton and the account because the remedy is one call on one /// contract — `setAuthorization(account, true)` from the owner's wallet — /// and an owner hitting this should not have to look either up. error AuthorizationRequired(address morpho, address authorized); error DebtSurvived(uint256 left); error CollateralDidNotArrive(); error AdapterKept(address asset, uint256 amount); constructor(AgentRegistry registry_) FlashEngine(registry_) {} function _requiresArming(bytes32 operation) internal pure override returns (bool) { return operation == OP_MIGRATE; } function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsOwnerMigrateMorphoAdapter", "1"); } /// Move the account's Morpho position into the owner's own name. /// /// No signature, no task id, no deadline — there is no agent. Idempotent by /// shape: run it twice and the second finds no debt. function migrateToOwner(Migrate calldata m) external { if (msg.sender != m.account) revert NotTheAccount(msg.sender); _requireOperation(m.account, OP_MIGRATE); address morpho = _venueNotRouter(m.account, m.venue); address owner = ownerOf(m.account); // THE MARKET, READ FROM THE CURATION as an exit — see the header. // Reverts UnknownMarket only for a name that was never listed. IMorpho.MarketParams memory p = curationOf(address(registry)).useMarket(m.account, m.market, false); // ACCRUED FIRST, BEFORE ANYTHING IS SIZED — finding 19 of the // 2026-09-26 audit. `owed` sizes the flash, and the repay below goes by // SHARES, which Morpho converts at the totals it accrues for itself. // Sized from the stale totals, the flash fell short by every second of // interest since the market was last touched, the exact approve refused // the repay, and the owner's migration reverted. Accruing here makes // every read below — the account's debt, the owner's position — the // numbers the calls that follow will see. IMorphoAccrue(morpho).accrueInterest(p); // WHAT IS OWED, IN ASSETS. Morpho stores borrow SHARES; the flash is in // assets, so the shares are converted through the market's own totals. uint256 owed = _owedAssets(morpho, p, m.account); if (owed == 0 || m.collateralAmount == 0) revert NothingToMigrate(); // REFUSED HERE, NOT FOUR FRAMES DOWN. Morpho would revert an // unauthorised borrow with its own error about the owner's position; // this names the call the owner has to make. if (!IMorphoAuth(morpho).isAuthorized(owner, m.account)) { revert AuthorizationRequired(morpho, m.account); } // THE OWNER'S POSITION, AT THIS BLOCK. Accrued above, so the debt read // here and the one read after the borrow — which accrues for itself — // are the same kind of number. (,, uint128 ownerColl) = IMorpho(morpho).position(keccak256(abi.encode(p)), owner); uint256 ownerDebt = _owedAssets(morpho, p, owner); uint256 heldBefore = IERC20Min(p.collateralToken).balanceOf(address(this)); _account = m.account; _flash( morpho, p.loanToken, owed, abi.encode( Ctx({ m: m, p: p, owner: owner, morpho: morpho, assets: owed, got: 0, heldBefore: heldBefore, ownerCollBefore: ownerColl, ownerDebtBefore: ownerDebt }) ) ); } /// The whole migration, inside Morpho's callback. function _onFlash(address, uint256, uint256, bytes memory data) internal override { Ctx memory c = abi.decode(data, (Ctx)); _repayTheAccount(c); _takeCollateral(c); _reopenForOwner(c); emit MigratedToOwner(c.m.account, c.owner, c.m.market, c.got, c.assets); } /// 1. REPAY THE ACCOUNT'S DEBT, from the flash this contract holds. /// /// BY SHARES, NOT BY ASSETS, and this is the one place the two differ in a /// way that matters. A fixed asset amount leaves dust debt — interest /// accrued between the quote and the block — and Morpho refuses to withdraw /// collateral while any debt remains, so the dust blocks the whole /// migration. Passing the account's full share count clears it exactly. /// /// Repay-on-behalf needs no authorisation on Morpho: it is a gift, as it is /// on Aave. function _repayTheAccount(Ctx memory c) private { bytes32 id = keccak256(abi.encode(c.p)); (, uint128 borrowShares,) = IMorpho(c.morpho).position(id, c.m.account); if (borrowShares == 0) revert NothingToMigrate(); _approveThisExactly(c.p.loanToken, c.morpho, c.assets); IMorpho(c.morpho).repay(c.p, 0, uint256(borrowShares), c.m.account, ""); (, uint128 left,) = IMorpho(c.morpho).position(id, c.m.account); if (left != 0) revert DebtSurvived(left); } /// 2. TAKE THE COLLATERAL OUT, to this contract. /// /// `withdrawCollateral` is one of the two Morpho calls ending in a RECEIVER /// rather than `bytes data` — the two that pay out — and the receiver here /// is this contract, because it is the party that must supply it onward. /// It is the ACCOUNT's collateral, so the call goes through `_exec`: Morpho /// gates `withdrawCollateral` on `isAuthorized(onBehalf, msg.sender)` and /// the account is trivially authorised for itself. /// /// Capped at the live figure. Measured as a delta, so a fee-on-transfer /// collateral supplies what arrived rather than what was asked for. function _takeCollateral(Ctx memory c) private { bytes32 id = keccak256(abi.encode(c.p)); (,, uint128 held) = IMorpho(c.morpho).position(id, c.m.account); uint256 want = c.m.collateralAmount; if (want > held) want = held; if (want == 0) revert NothingToMigrate(); uint256 before = IERC20Min(c.p.collateralToken).balanceOf(address(this)); _exec( c.m.account, c.morpho, 0, abi.encodeCall( IMorpho.withdrawCollateral, (c.p, want, c.m.account, address(this)) ) ); c.got = IERC20Min(c.p.collateralToken).balanceOf(address(this)) - before; if (c.got == 0) revert CollateralDidNotArrive(); } /// 3 and 4. PUT IT BACK IN THE OWNER'S NAME, collateral then debt. /// /// `supplyCollateral` on behalf needs no permission — a gift. The BORROW is /// the leg the owner's `setAuthorization` pays for, and it runs AS THE /// ACCOUNT, so the authorisation Morpho reads is the one the owner gave this /// account (finding 11 — see the header). The RECEIVER is this contract, /// which is what settles the flash: Morpho pulls the repayment after this /// returns. function _reopenForOwner(Ctx memory c) private { _approveThisExactly(c.p.collateralToken, c.morpho, c.got); IMorpho(c.morpho).supplyCollateral(c.p, c.got, c.owner, ""); _exec( c.m.account, c.morpho, 0, abi.encodeCall(IMorpho.borrow, (c.p, c.assets, 0, c.owner, address(this))) ); // NOTHING OF THE COLLATERAL STAYS — and measured as a RISE, finding 9. // FlashEngine's delta check watches the FLASH token only; the // collateral passes through here too, and a path that forgot to supply // it would leave it here. This read `kept != 0` — FlashEngine's old // High in a second asset: one wei sent to this shared contract refused // every migration from that market, for every account, for good. uint256 kept = IERC20Min(c.p.collateralToken).balanceOf(address(this)); if (kept > c.heldBefore) { revert AdapterKept(c.p.collateralToken, kept - c.heldBefore); } // THE OWNER'S POSITION IS NO MORE LEVERED THAN IT WAS — finding 11's // second layer. Cross-multiplied in the market's own units, so no // oracle: debt·collateral before against after, the form // `LoanHealth.requireNoMoreLevered` uses for Aave. An owner with no // collateral here before — the ordinary destination — passes; a victim // with collateral and no debt, the lookalike attack's precondition, and // any rise in the owner's own leverage, do not. // // Dust collateral with no debt is treated as empty: a 1-wei donation // otherwise DoSes every Morpho owner migration. if (c.ownerDebtBefore == 0) { if (c.ownerCollBefore <= LoanHealth.DUST_COLLATERAL) return; _requireMovedWithinCeiling(c); return; } (,, uint128 collAfter) = IMorpho(c.morpho).position(keccak256(abi.encode(c.p)), c.owner); LoanHealth.requireNoMoreLeveredBps( c.ownerDebtBefore * uint256(collAfter), _owedAssets(c.morpho, c.p, c.owner) * c.ownerCollBefore ); } /// AN OWNER WITH NO DEBT AND REAL COLLATERAL — audit #44 of 2026-10-08. /// /// The no-more-levered ratio refuses ANY debt onto an owner with none, so /// anybody could block every Morpho migration to an owner by supplying /// `DUST_COLLATERAL + 1` of collateral on their behalf, which Morpho lets /// anyone do. The dust floor cannot simply be dropped (the audit's /// "Option A"): it is also what stops a lookalike account, holding the /// owner's consent somehow, from moving its debt onto collateral the owner /// already held — `test_AnOwnerHoldingCollateralCannotBeHandedTheDebt`. /// /// So the owner's resulting LTV is held to the account's ceiling — and /// measured on WHAT THIS MIGRATION BROUGHT, the debt over the collateral it /// supplied (`c.got`), not counting what the owner already held. Counted, /// the owner's own collateral would carry a lookalike's debt up to the /// ceiling; not counted, a donation changes nothing (it is neither moved /// debt nor moved collateral), an honest position migrates whole, and the /// owner's whole position is at most this levered, since what they held /// only lowers it. Valued at the market's oracle, Morpho's own arithmetic. function _requireMovedWithinCeiling(Ctx memory c) private { uint256 value = (c.got * IMorphoOracle(c.p.oracle).price()) / 1e36; uint256 debt = LoanHealth.debtOf(c.morpho, c.p, c.owner); _requireLtvCeiling( c.m.account, value == 0 ? type(uint256).max : (debt * 10_000 + value - 1) / value ); } /// What the account owes in LOAN TOKEN units. /// /// Morpho stores borrow SHARES and the flash must be sized in assets, so the /// conversion goes through the market's totals — and it has to be MORPHO'S /// conversion, to the wei, because `repay` by shares charges exactly /// `toAssetsUp(shares)` and the approve is exact. That is SharesMathLib /// with its virtual shares and assets (1e6 and 1), rounded up, which is /// `LoanHealth.assetsFor`. The plain `shares * assets / totalShares` this /// used omits them (finding 19): under Morpho's figure the repay pulls /// more than was approved and reverts; over it, the surplus stays here and /// FlashEngine's delta check refuses it. The totals are read as they stand, /// so the caller accrues first. function _owedAssets(address morpho, IMorpho.MarketParams memory p, address account) internal view returns (uint256) { bytes32 id = keccak256(abi.encode(p)); (, uint128 shares,) = IMorpho(morpho).position(id, account); if (shares == 0) return 0; return LoanHealth.assetsFor(morpho, id, shares); } function _approveThisExactly(address asset, address spender, uint256 amount) internal { _okTokenCall(asset, abi.encodeCall(IERC20Min.approve, (spender, amount))); } }