// 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"; interface IERC20Min { function approve(address spender, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address who) external view returns (uint256); } interface IAaveV3 { function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; function borrow( address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf ) external; function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) external returns (uint256); function withdraw(address asset, uint256 amount, address to) external returns (uint256); function getUserAccountData(address user) external view returns (uint256, uint256, uint256, uint256, uint256, uint256); } /// Aave's variable debt token. `borrowAllowance` is the owner's consent, and it /// is the only part of this the owner cannot delegate to us by installing a /// module — Aave enforces it, in Aave's own storage. interface IVariableDebtToken { function borrowAllowance(address fromUser, address toUser) external view returns (uint256); } /// @title OwnerMigrateAaveV3Adapter — the OWNER moves their own leveraged position out. /// /// # What this is for /// /// `cmd/api/withdraw.go` lets an owner take BALANCES out of their operating /// account by signing a bare `Kernel.execute`. It cannot take out a POSITION: /// Aave collateral sitting under a loan cannot be transferred, and nothing in /// this system moves a debt at all. This moves both — collateral and debt — from /// the account into the owner's own name, in one transaction the owner signs. /// /// # Why it could not be an ordinary adapter /// /// Every other adapter here is driven by B and hardcodes the account as the /// beneficiary of every venue call. `LoanCloseAdapter` states the rule: the /// recipient "is the account and is never a parameter". That is what stops a /// stolen agent key naming itself, and it must not be relaxed — so an operation /// whose whole purpose is to send somewhere OTHER than the account cannot live /// in a contract B drives. /// /// `BaseAdapter._consume` has no owner path either: `msg.sender == agent`, or a /// signature recovering to the agent. So this contract does not call it. /// /// # What stands in its place /// /// THE ACCOUNT IS THE CALLER. `migrateToOwner` refuses anything but /// `msg.sender == m.account`, and `Kernel.execute` is root-validator-only, so /// the owner is the only party that can INTEND this call. /// /// That alone is not proof, and this contract does not pretend it is. It was /// written while an agent could hold `execute` behind a hook that asked the /// ACCOUNT, not the registry, whether a target was an installed executor — so /// installing this made it reachable, and the agent could reach it through its /// validator's `execute`. The agent holds no validator now, but anything that /// ever does inherits that route, so the safety still does not rest on the /// caller check. /// /// IT RESTS ON THE DESTINATION INSTEAD. There is no `to` anywhere in this /// contract's inputs. Every beneficiary is `ownerOf(account)`, read per call /// from the account's own root validator, exactly as `OwnerTransferAdapter` /// does. The worst outcome of any authorisation failure here is that somebody /// sends the owner their own position, sooner than they meant to. That is a /// timing problem, not a theft — and it is why this contract takes PARAMETERS /// and never CALLDATA. An owner-installed adapter that executed arbitrary calls /// would hand the agent arbitrary execution, which is the one thing the whole /// key model exists to prevent. /// /// AND AAVE HOLDS THE LAST LOCK. Opening debt in the owner's name needs /// `approveDelegation`, signed by the owner, stored in Aave. Nothing here can /// fabricate it, so nothing migrates that the owner has not already authorised. /// /// THE DELEGATEE IS THE ACCOUNT, NOT THIS CONTRACT — finding 8 of the /// 2026-09-26 audit. It used to be this contract, on the theory that one /// contract whose only use of the authority is this migration was the /// narrowest grant. It was the widest: this contract is shared, and the only /// thing tying an account to "its" owner is `ownerOf`, which for a passkey root /// is an owner OF RECORD that whoever creates the account writes. So an /// attacker built an account they controlled that named a victim, and moved /// its own debt onto the victim through the victim's standing delegation — with /// one wei of collateral, keeping the rest. The borrow now runs AS THE ACCOUNT, /// so Aave asks for a delegation to that account, exactly as /// `PullFromOwnerAdapter` spends an allowance the owner gave that account and /// nobody else. A lookalike account has none. /// /// AND THE OWNER'S OWN POSITION MAY NOT END MORE LEVERED THAN IT BEGAN — the /// second layer, independent of how the consent was obtained. An owner with no /// position, the ordinary destination, passes trivially. /// /// # B's only role /// /// This adapter is catalogued OFF-CHAIN and is mapped to no `chaincfg.Operation` /// — see internal/plan/offered.go, where an off-chain step "compiles, /// deliberately to nothing". B can put "move this position to the owner" in a /// plan, where a person reads it and acts. It cannot build a transaction for it, /// because there is no intent shape for it to build. /// /// AND `Agent.callAdapter` IS CLOSED BY THE CALLER CHECK, NOT BY THE WHITELIST. /// An earlier draft of this paragraph said this adapter is "never whitelisted in /// AgentRegistry", and that could not have been true of a working deployment: /// `AdapterInstaller.activate` installs only what `registry.adapters()` lists /// and takes its payload from `registry.installDataFor`, so an adapter the /// guardian never whitelisted cannot be installed at all — and an adapter that /// is never installed cannot be called by anybody, including the owner. It is /// whitelisted, by `cmd/curate`, like every other adapter here. /// /// What actually closes that door is `migrateToOwner` itself. `Agent._callOne` /// reaches the adapter with the AGENT PROXY as `msg.sender`, which is not the /// account, so the first line of the function reverts `NotTheAccount`. That /// holds whatever the registry says, and it does not depend on a curation run /// having omitted something. /// /// The whitelist was never the boundary, and it is worth being exact about /// which of the two it is: one of them is a fact about this contract's code and /// the other is a fact about a deployment script's behaviour. contract OwnerMigrateAaveV3Adapter is FlashEngine { /// ARMING, AND FOR THE REASON BaseAdapter RESERVES IT. /// /// That header names `pull_from_owner` and `owner_transfer` as "the only two /// that reach outside the account", and this is a third. The consequence /// that matters is in `onInstall`: it REVERTS on an arming-class operation /// in the payload, so a guardian's curation cannot grant this one. /// /// BUT THE CLASS IS ON BY DEFAULT, AND READING IT THE OTHER WAY IS A BUG /// WAITING TO BE WRITTEN. `_requireOperation` does NOT consult `allows` for /// an arming-class operation — it asks whether the account has DISARMED it: /// /// if (registry.disarmed(account, operation)) revert OperationNotAllowed(...); /// /// `disarmed` is a mapping nobody has written, an unwritten slot reads /// false, and false means PERMITTED. So a freshly installed account can send /// `migrateToOwner` with no arming transaction at all, and `armOperation` /// exists to switch this OFF again (and back on), never to switch it on for /// the first time. /// /// THE CONSEQUENCE FOR ANYTHING BUILDING A TRANSACTION LIST: do not emit an /// unconditional arm call before the migration. Read /// `disarmed(account, "migrate_to_owner")` and emit one only if it comes /// back true, exactly as the deposit path reads it — "armed is the absence /// of a switch-off". An unconditional one is a signature the owner is asked /// for and does not owe. /// /// `armOperation` is keyed by msg.sender, so only the account's own call /// moves the switch, and the agent has no `execute` with which to make the /// account call it: the agent can neither arm nor disarm this. bytes32 public constant OP_MIGRATE = "migrate_to_owner"; /// What to move, and nothing about where it goes. struct Migrate { address account; /// The Aave pool, resolved through the account's curated venues rather /// than taken as an address — a pool is a venue, and `_venueNotRouter` /// is where this repo decides what an account may call. bytes32 venue; /// The Morpho instance the flash comes from. Also a venue. bytes32 flashVenue; address collateral; address loanToken; /// How much collateral to move. Capped at what the account actually /// holds — see the aToken note in `_onFlash`. uint256 collateralAmount; // No `to`, no calldata, no route. There is one destination and this // contract reads it; there is one sequence and this contract owns it. } /// EVERYTHING THE CALLBACK NEEDS, IN MEMORY RATHER THAN ON THE STACK. /// /// `via_ir` is pinned OFF in foundry.toml — deterministic CREATE2 addresses /// depend on byte-identical output — and the migration in one body runs the /// stack out: solc refused at the final `emit` with "Stack too deep". /// LoanShiftAdapter hit the same wall and answered it by splitting into four /// helpers that read `_account` from transient storage; this answers it by /// carrying one pointer instead of eight words, which costs no transient /// slots and keeps the sequence readable top to bottom. struct Ctx { Migrate m; address owner; address pool; address debtToken; address token; 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 7). uint256 heldBefore; /// The owner's Aave position before, in base currency — what the /// no-more-levered check compares against (finding 8). uint256 ownerCollBefore; uint256 ownerDebtBefore; } event MigratedToOwner( address indexed account, address indexed owner, address collateral, uint256 collateralAmount, address loanToken, uint256 debt ); error NotTheAccount(address caller); error NothingToMigrate(); /// The owner has not delegated enough credit to THE ACCOUNT. Carries what /// to call and on what, because this is the one refusal an owner is expected /// to hit and it has an exact remedy: `approveDelegation(account, needed)` /// on `debtToken`, from the owner's wallet. error DelegationRequired(address debtToken, address delegatee, uint256 needed); error DebtSurvived(uint256 left); /// The borrow in the owner's name did not pay the account exactly what was /// asked, so there is nothing exact to settle the flash with. error BorrowDidNotArrive(address token, uint256 wanted); error CollateralDidNotArrive(address asset); 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 ("DeFiLoopsOwnerMigrateAaveV3Adapter", "1"); } /// Move the account's Aave v3 position into the owner's own name. /// /// NO SIGNATURE, NO TASK ID, NO DEADLINE — because there is no agent. Those /// three exist on every other entry point to bound what B may do with an /// authority it was given once; here the authority IS the call, and it comes /// from the root validator on every invocation. /// /// IDEMPOTENT BY SHAPE rather than by a spent-task map. Run it twice and the /// second finds no debt and reverts `NothingToMigrate` — there is nothing a /// replay could take, because every destination is the owner. function migrateToOwner(Migrate calldata m) external { // THE ACCOUNT, WHICH UNDER KERNEL MEANS THE ROOT VALIDATOR SAID SO. // `execute` is onlyEntryPointOrSelfOrRoot, so this call can only be // intended by the owner. See the header for why this is not the whole // argument. if (msg.sender != m.account) revert NotTheAccount(msg.sender); _requireOperation(m.account, OP_MIGRATE); address pool = _venueNotRouter(m.account, m.venue); address owner = ownerOf(m.account); (, uint256 debt,,,,) = IAaveV3(pool).getUserAccountData(m.account); if (debt == 0 || m.collateralAmount == 0) revert NothingToMigrate(); // WHAT IS OWED IN THE LOAN TOKEN, not the USD figure above. // getUserAccountData answers in base currency across every reserve; the // flash is one asset, so the size comes from the debt token's balance. address debtToken = _variableDebtOf(pool, m.loanToken); uint256 owed = IERC20Min(debtToken).balanceOf(m.account); if (owed == 0) revert NothingToMigrate(); // REFUSED HERE, NOT INSIDE THE CALLBACK. Aave would revert on the borrow // four frames down with an error about the owner's position; this names // the call the owner has to make, which is the whole difference between // a refusal somebody can act on and one they have to investigate. uint256 allowed = IVariableDebtToken(debtToken).borrowAllowance(owner, m.account); if (allowed < owed) revert DelegationRequired(debtToken, m.account, owed); (uint256 ownerColl, uint256 ownerDebt,,,,) = IAaveV3(pool).getUserAccountData(owner); uint256 heldBefore = IERC20Min(m.collateral).balanceOf(address(this)); _account = m.account; _flash( _venueNotRouter(m.account, m.flashVenue), m.loanToken, owed, abi.encode( Ctx({ m: m, owner: owner, pool: pool, debtToken: debtToken, token: m.loanToken, assets: owed, got: 0, heldBefore: heldBefore, ownerCollBefore: ownerColl, ownerDebtBefore: ownerDebt }) ) ); } /// The whole migration, inside Morpho's callback. /// /// ORDER IS LoanShiftAdapter'S, with supply and borrow re-pointed at the /// owner. The one structural difference is WHO HOLDS THE MONEY: there, the /// flash is pushed to the account and every venue call is `_exec`'d through /// it. Here the adapter keeps the flash and repays on the account's BEHALF — /// repay-on-behalf is permissionless on Aave — because the adapter is also /// the party that must end up holding the borrowed token to settle. 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.collateral, c.got, c.token, c.assets ); } /// 1. REPAY THE ACCOUNT'S DEBT, from the flash this contract holds. /// /// ON THE ACCOUNT'S BEHALF, from here — repay-on-behalf is permissionless on /// Aave, and it saves pushing the flash into the account and pulling it back. /// /// AN EXACT AMOUNT, AND AAVE FORBIDS THE ALTERNATIVE HERE. /// /// This passed AAVE_ALL — `type(uint256).max`, which Aave reads as "all of /// the debt" — on the reasoning that debt accrues every block and a figure /// fixed when the transaction was built is already short by the time it /// lands. The reasoning is sound and the call was not: Aave's /// `validateRepay` requires /// /// amountSent != type(uint256).max || msg.sender == onBehalfOf /// /// and reverts `NoExplicitAmountToRepayOnBehalf` (0xcd3779c3) otherwise. /// THE SENTINEL IS ONLY FOR THE DEBTOR REPAYING THEMSELVES. Here the ADAPTER /// holds the flash and repays on the ACCOUNT's behalf, so msg.sender is /// never onBehalfOf and this reverted on every migration that got as far as /// the callback. contracts/test/OwnerMigrateAaveV3.t.sol is what found it. /// /// WHY EVERY OTHER ADAPTER HERE GETS AWAY WITH IT: LoanShift, LoanClose and /// DeleverageAaveV3 all repay through `_exec(account, pool, ...)`, so the /// ACCOUNT is msg.sender AND onBehalfOf and the check passes. This one /// cannot take that route, because it must end up holding the borrowed token /// to settle the flash. /// /// SO IT READS THE DEBT HERE rather than reusing the figure the entry point /// sized the flash from — and that read is exact rather than merely fresh. /// `balanceOf` on a variable debt token extrapolates the borrow index to /// `block.timestamp`, and the entry point and this callback are the same /// transaction at the same timestamp, so nothing has accrued between them. /// A residue is still refused below rather than assumed away. function _repayTheAccount(Ctx memory c) private { uint256 owed = IERC20Min(c.debtToken).balanceOf(c.m.account); if (owed == 0) revert NothingToMigrate(); // NEVER MAX AS AN APPROVAL EITHER, for the separate reason it always // was: an infinite allowance to the pool would outlive this call. _approveThisExactly(c.token, c.pool, owed); IAaveV3(c.pool).repay(c.token, owed, AAVE_VARIABLE, c.m.account); uint256 left = IERC20Min(c.debtToken).balanceOf(c.m.account); if (left != 0) revert DebtSurvived(left); } /// 2. TAKE THE COLLATERAL OUT, to this contract. /// /// AAVE'S aTOKEN ROUNDS DOWN. An account that supplied exactly 10 ether /// holds 9999999999999999999, so "move all of it" — the case that matters /// most — is refused with NotEnoughAvailableUserBalance, one wei short, /// every time. Capped at the live balance, as LoanShiftAdapter does. /// /// MEASURED, NOT ASSUMED. `got` is the delta this contract actually /// received, so a fee-on-transfer collateral supplies what arrived rather /// than what was asked for. function _takeCollateral(Ctx memory c) private { uint256 want = c.m.collateralAmount; uint256 have = IERC20Min(_aTokenOf(c.pool, c.m.collateral)).balanceOf(c.m.account); if (have < want) want = have; if (want == 0) revert NothingToMigrate(); uint256 held = IERC20Min(c.m.collateral).balanceOf(address(this)); _exec( c.m.account, c.pool, 0, abi.encodeCall(IAaveV3.withdraw, (c.m.collateral, want, address(this))) ); c.got = IERC20Min(c.m.collateral).balanceOf(address(this)) - held; if (c.got == 0) revert CollateralDidNotArrive(c.m.collateral); } /// 3 and 4. PUT IT BACK IN THE OWNER'S NAME, collateral then debt. /// /// supply-on-behalf needs no permission from the beneficiary — it is a gift, /// and Aave treats it as one. The BORROW is the leg that needs the owner's /// `approveDelegation`, checked before the flash was taken, and it runs AS /// THE ACCOUNT so that the delegation Aave reads is the one the owner gave /// this account (finding 8 — see the header). Aave pays the borrow to its /// caller, the account, which forwards exactly that to this contract: that /// is what settles the flash, since FlashEngine approves the lender after /// this returns and Morpho pulls rather than expecting a push. function _reopenForOwner(Ctx memory c) private { _approveThisExactly(c.m.collateral, c.pool, c.got); IAaveV3(c.pool).supply(c.m.collateral, c.got, c.owner, uint16(0)); uint256 before = IERC20Min(c.token).balanceOf(c.m.account); _exec( c.m.account, c.pool, 0, abi.encodeCall(IAaveV3.borrow, (c.token, c.assets, AAVE_VARIABLE, uint16(0), c.owner)) ); if (IERC20Min(c.token).balanceOf(c.m.account) - before != c.assets) { revert BorrowDidNotArrive(c.token, c.assets); } bytes memory ok = _exec(c.m.account, c.token, 0, abi.encodeCall(IERC20Min.transfer, (address(this), c.assets))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert BorrowDidNotArrive(c.token, c.assets); // NOTHING OF THE COLLATERAL STAYS — and measured as a RISE, finding 7. // FlashEngine's delta check covers the FLASH token and only that one; // the collateral passes through this contract too, and a path that // forgot to supply it would leave it here. This read `kept != 0`, which // is FlashEngine's old High in a second asset: the contract is shared, // so one wei sent to it by anybody refused every migration in that // collateral for every account, for good. Donated dust is ignored now; // a rise is still refused. uint256 kept = IERC20Min(c.m.collateral).balanceOf(address(this)); if (kept > c.heldBefore) revert AdapterKept(c.m.collateral, kept - c.heldBefore); // THE OWNER'S POSITION IS NO MORE LEVERED THAN IT WAS — finding 8's // second layer, the M-5 rule applied to the destination. An owner with // no position passes; a victim with collateral and no debt — the // precondition of the lookalike attack — does not, and neither does any // rise in the owner's own leverage. // // Dust collateral with no debt is treated as empty: a 1-wei donation to // the owner otherwise DoSes every migration (`was=0`, `now_=debt*dust`). if (c.ownerDebtBefore == 0) { if (c.ownerCollBefore <= LoanHealth.DUST_COLLATERAL) return; _requireMovedWithinCeiling(c); return; } LoanHealth.requireNoMoreLevered(c.pool, c.owner, c.ownerCollBefore, c.ownerDebtBefore); } /// AN OWNER WITH NO DEBT AND REAL COLLATERAL — audit #43 of 2026-10-08. /// /// The no-more-levered ratio refuses ANY debt onto an owner with none, so /// anybody could block every Aave migration to an owner by supplying a /// little over `DUST_COLLATERAL` (base units: a thousandth of a cent) on /// their behalf, which Aave 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 owner's debt (all of it /// moved here, since there was none) over the collateral the owner gained, /// both in the pool's base currency and read in this transaction. What the /// owner already held is not counted: counted, it would carry a /// lookalike's debt up to the ceiling; not counted, a donation changes /// nothing, an honest position migrates whole, and the owner's whole /// position is at most this levered, since what they held only lowers it. function _requireMovedWithinCeiling(Ctx memory c) private view { (uint256 collAfter, uint256 debtAfter,,,,) = IAaveV3(c.pool).getUserAccountData(c.owner); uint256 moved = collAfter > c.ownerCollBefore ? collAfter - c.ownerCollBefore : 0; _requireLtvCeiling( c.m.account, moved == 0 ? type(uint256).max : (debtAfter * 10_000 + moved - 1) / moved ); } /// Approve exactly, from this contract, tolerating a token that returns /// nothing. `_approveFrom` is the account's version; this is the adapter's. function _approveThisExactly(address asset, address spender, uint256 amount) internal { _okTokenCall(asset, abi.encodeCall(IERC20Min.approve, (spender, amount))); } /// The variable debt token, by the same two-shape probe `_aTokenOf` uses: /// newer pools answer `getReserveVariableDebtToken`, classic V3 only has /// `getReserveData`, where it is word 10 of a fifteen-word struct. function _variableDebtOf(address pool, address asset) internal view returns (address dt) { (bool ok, bytes memory ret) = pool.staticcall( abi.encodeWithSelector( bytes4(keccak256("getReserveVariableDebtToken(address)")), asset ) ); if (ok && ret.length >= 32) { dt = abi.decode(ret, (address)); if (dt != address(0)) return dt; } (ok, ret) = pool.staticcall(abi.encodeWithSelector(bytes4(keccak256("getReserveData(address)")), asset)); if (!ok || ret.length < 352) revert NothingArrived(asset); assembly { dt := mload(add(ret, 352)) } } }