// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {curationOf} from "../AdapterCuration.sol"; import {IMorpho} from "./MorphoAdapter.sol"; import {IAggregationRouter} from "./SwapAdapter.sol"; import {LoanHealth} from "./lib/LoanHealth.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// Aave v4's spoke, which is a DIFFERENT PROTOCOL from v3 and not a newer one. /// /// An asset here is a NUMERIC RESERVE ID belonging to the spoke, not an address: /// USDC is reserve 7 in the Main Spoke and 6 in the Gold one. The id is never a /// parameter on a shift -- it is looked up from the protocol's reserve table /// (AdapterCuration, keyed by the spoke), so a wrong id is a curation error /// rather than something a signed message can carry. interface IAaveV4Spoke { function supply(uint256 reserveId, uint256 amount, address onBehalfOf) external; function withdraw(uint256 reserveId, uint256 amount, address onBehalfOf) external; function borrow(uint256 reserveId, uint256 amount, address onBehalfOf) external; function repay(uint256 reserveId, uint256 amount, address onBehalfOf) external; /// The one that makes a v4 supply COUNT. Until this is called the supply is /// a deposit and nothing more, and borrowing against it fails with a /// health-factor error naming a position the protocol does not think exists. function setUsingAsCollateral(uint256 reserveId, bool useAsCollateral, address onBehalfOf) external; } /// The two calls a close needs from Aave, in Aave's own argument order. /// /// `withdraw`'s third argument is a RECEIVER and `repay`'s is an onBehalfOf — /// the AaveV3Adapter carries a comment about exactly that, because copying one /// to make the other is how a withdrawal ends up sent somewhere nobody chose. interface IAavePool { function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) external returns (uint256); function withdraw(address asset, uint256 amount, address to) external returns (uint256); /// Aave 3.2, for capping a withdraw at the aToken balance. Not on the /// classic V3 pools — `_aTokenOf` falls back to `getReserveData` there. function getReserveAToken(address asset) external view returns (address); /// Six aggregate words in the pool's BASE CURRENCY. Only the first two are /// read here — total collateral and total debt — and they are the whole of /// M-5: an Aave account is ONE cross-collateralised position, not a set of /// isolated markets. See `_requireNoMoreLevered`. function getUserAccountData(address user) external view returns (uint256, uint256, uint256, uint256, uint256, uint256); /// Variable-debt receipt, for pro-rata partial closes. Same 3.2-vs-classic /// split as `getReserveAToken`; `_debtTokenOf` falls back to `getReserveData`. function getReserveVariableDebtToken(address asset) external view returns (address); /// The two a RE-ENTRY needs, and their argument orders do not match each /// other. `supply` puts onBehalfOf third and the referral code fourth; /// `borrow` puts the referral code fourth and onBehalfOf FIFTH. Reading one /// off the other lands a borrow on the referral slot and credits the debt to /// address(0). Copied from AaveV3Adapter, which carries the same warning. function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; function borrow( address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf ) external; } interface IMorphoFlash { function flashLoan(address token, uint256 assets, bytes calldata data) external; /// `borrowShares` is the only exact handle on a debt. See `Shift.repayAll`. function position(bytes32 id, address user) external view returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral); /// Bring the market's totals to this block, so a share can be priced. function accrueInterest(IMorpho.MarketParams calldata m) external; /// The totals a share is converted against. Only the borrow pair is used. function market(bytes32 id) external view returns ( uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee ); } /// @title LoanShiftAdapter — move a leveraged position from one market to /// another, atomically, without the owner having to find the money to unwind it. /// /// # The problem it solves /// /// Flipping a position means repaying a debt you do not have the cash to repay. /// Long cbBTC is *collateral cbBTC, debt USDC*; short cbBTC is *collateral USDC, /// debt cbBTC*. Getting from one to the other by hand means finding the USDC to /// clear the debt, waiting to withdraw, selling, re-supplying and re-borrowing — /// five transactions, and the position is unhedged in the middle of every one of /// them. /// /// A flash loan removes the need for the money and the exposure at once. Morpho /// charges nothing for it, so the whole cost of the flip is the swap and the /// gas. /// /// # Why the ADAPTER takes the loan and not the account /// /// Morpho calls `onMorphoFlashLoan` on whoever called `flashLoan`. If the /// account borrowed, the account would need that callback — a second entry point /// on the thing that holds the money, reachable by any contract that can get the /// account to call it. The adapter borrowing instead keeps the account exactly as /// dumb as it is for every other adapter: it does what it is told, one call at a /// time, through `executeFromExecutor`. /// /// The adapter therefore holds tokens, briefly, inside one transaction. It ends /// every successful call holding nothing — asserted, not assumed, because an /// adapter with a balance is an adapter somebody can drain. /// /// # Two shapes, one swap each /// /// The collateral always sells into `to.collateralToken` — that is what the new /// market wants and there is no other candidate. What changes is where the flash /// loan is repaid FROM, and that is decided by which token the two markets share: /// /// **FLIP** — `to.collateralToken == from.loanToken`. Long cbBTC (collateral /// cbBTC, debt USDC) becomes short cbBTC (collateral USDC, debt cbBTC). The sale /// produces the borrowed asset, so the loan comes out of the proceeds and what /// is left over becomes the new collateral. /// /// **MIGRATE** — `to.loanToken == from.loanToken`. Move a USDC debt from a cbBTC /// market to a WETH one: same debt asset, different collateral and different /// oracle. The sale produces the new collateral, all of it is supplied, and the /// loan is repaid out of the NEW borrow — which is therefore required to cover /// it. /// /// Anything else needs a second swap and a second floor, and is refused. A shift /// whose second leg had no price the owner signed is not a smaller version of /// this operation; it is a different one with a hole in it. /// /// # What a wrong route can do /// /// The same as in `SwapAdapter`, and bounded the same way. The route is computed /// off chain and is untrusted; `minOut` is checked against the account's measured /// balance delta, which the router cannot fake. A bad route makes a worse fill, /// and a fill worse than the floor reverts the whole flip — including the repay /// and the withdraw, because it is all one transaction. contract LoanCloseAdapter is BaseAdapter { /// What the position is meant to BE when this finishes. /// /// # Why a mode at all, when the markets already say the shape /// /// They say the shape; they do not say the intent. A plan that meant to go /// market-neutral and named a directional market is well-formed, compiles, /// and opens a leveraged bet — and nothing anywhere would object, because /// every individual field is valid. The mode is the owner stating what they /// think they are signing, and this contract refusing when the market does /// not agree. /// /// What each one actually asserts, and what it does not: /// /// - `Neutral` — **exact.** `to.loanToken == to.collateralToken`: the same /// asset is supplied and borrowed, so the position carries a rate spread /// and no price exposure. Checked both ways — a same-asset market may only /// be entered as Neutral, and Neutral may not name any other market. /// - `Short` — **exact.** A true reversal: the destination's collateral is /// the origin's debt asset and its debt is the origin's collateral. This /// is the long-to-short flip and nothing else satisfies it. /// - `Long` — **directional, and that is all it proves.** The two tokens /// differ. It covers entering a directional position and migrating one /// between markets, and it deliberately does not try to distinguish those /// from the token addresses, because it cannot. /// WHICH protocol holds the position being closed. /// /// Separate from where the flash loan comes from, and deliberately: Morpho /// lends flash for nothing and Aave charges a premium, so the flash always /// comes from Morpho whatever holds the position. There is no reason the /// two must be the same protocol and a real reason they should not be. /// AaveV4 is a DIFFERENT PROTOCOL from AaveV3, not a newer version of it. /// Its ordinal must match intent.LenderAaveV4 and the sibling adapter's own /// Lender, because all three cross the wire as the same uint8. enum Lender { Morpho, AaveV3, AaveV4 } /// Aave's variable-rate mode. Stable rate is deprecated and was never /// offered here; passing 1 would repay a debt the account does not have. /// HOW FAR PAST THE FLASH A DEBT MAY BE AND STILL BE REPAID — H-8 of the /// round-2 audit. /// /// The honest gap between the flash amount and the debt is INTEREST accrued /// between the plan being written and the block that runs it, which is basis /// points. Five percent is a hundred times the largest gap this code was /// written against, so it never binds on a real close — and it is what stops /// a venue that answers `_assetsFor` with 1e60 from sizing its own /// allowance. uint256 internal constant MAX_DEBT_SLACK_BPS = 500; uint256 internal constant SLACK_BPS_BASE = 10_000; uint256 internal constant AAVE_VARIABLE = 2; /// Aave's "all of it" for both repay and withdraw. /// /// This is why an Aave close is SIMPLER than a Morpho one. Morpho holds debt /// in shares that accrue, so clearing it exactly means reading /// `borrowShares` and repaying by share — see `Shift.repayAll` and what a /// fixed asset amount was measured leaving behind. Aave takes `max` to mean /// the whole balance and works it out itself. uint256 internal constant AAVE_ALL = type(uint256).max; /// What A authorises at install. Distinct from `swap` and from Morpho's /// four: an owner who allowed supplying and borrowing has not thereby /// allowed something to sell their collateral and reverse their position. /// Separate from `loan_shift`, and not a convenience split. /// /// Closing sells the collateral and ends the position; shifting moves it. /// An owner who authorised moving a position between markets has not thereby /// authorised something to liquidate it into cash, and the two should be /// refusable independently. bytes32 public constant OP_CLOSE = "loan_close"; bytes32 internal constant SHIFT_TYPEHASH = keccak256( "Shift(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 venue," "bytes32 router,bytes32 fromMarket,bytes32 toMarket,uint256 repayAmount,bool repayAll," "uint256 collateralAmount,uint256 minOut,uint256 borrowAmount,uint16 maxFeeBps,uint8 mode," "bytes32 aaveVenue,uint8 fromLender,uint8 toLender)" ); bytes32 internal constant CLOSE_TYPEHASH = keccak256( "Close(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 venue," "bytes32 router,bytes32 market,bytes32 flashMarket,bytes32 positionVenue,uint8 lender," "uint256 repayAmount,bool repayAll,uint256 collateralAmount,uint256 entryMinOut," "uint256 minOut,uint16 maxFeeBps,bool partialFill)" ); /// Unwind a position entirely, or partly, into the asset that was owed. /// /// The one operation nobody can do by hand when they need it most: clearing /// a debt requires money the position exists BECAUSE you did not have. A /// flash loan supplies it for the length of one transaction, the collateral /// pays it back, and what is left over is the owner's. struct Close { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 venue; bytes32 router; /// The market being left. There is no destination — that is the point. bytes32 market; /// WHICH asset to flash-borrow, named by a curated market whose loan /// token it is. Zero means the debt asset itself. /// /// # Why this is not always the debt asset /// /// A flash loan can only borrow what the lender actually holds. Morpho /// will lend USDC all day; it may hold no cbBTC at all, and a short /// position — collateral USDC, debt cbBTC — then cannot be closed by /// flashing what it owes. The position is not stuck for any economic /// reason: the collateral is worth more than the debt. It is stuck /// because the one asset needed for a moment is unavailable. /// /// So the route goes around: flash the asset that IS available, buy the /// debt asset with it, repay, free the collateral, sell that back into /// the flashed asset, and hand it over. Two swaps instead of one, and /// therefore two floors — see `entryMinOut`. /// /// A market NAME rather than an address, for the same reason every other /// asset in this system is named: a plan cannot reach a token nobody /// curated. bytes32 flashMarket; /// WHERE THE POSITION IS, when that is not the flash lender. /// /// `venue` names the Morpho instance the flash loan comes from. This /// names the contract that actually holds the debt and the collateral — /// the same Morpho for a Morpho position, an Aave pool for an Aave one. /// Two fields because they are two things: Morpho lends flash for /// nothing and Aave charges a premium, so the flash always comes from /// Morpho whatever holds the position. bytes32 positionVenue; /// Which protocol `positionVenue` is. See [`Lender`]. uint8 lender; /// The flash-loan size, and a CEILING in both modes. /// /// It used to be the exact repay when `repayAll` was false, which made a /// partial close two independent numbers — some collateral out, some /// debt cleared — and the position left behind carried whatever leverage /// fell out of the pair. A close now always repays in proportion to the /// collateral it frees, so this only has to be large enough. uint256 repayAmount; /// Clear the WHOLE debt, by shares. See `Shift.repayAll` — a fixed asset /// amount leaves the interest that accrued since planning, and dust debt /// blocks the withdraw that has to follow it. /// /// Strictly a shortcut: a close of the whole collateral repays every /// share by proportion anyway. It stays because a caller who wants the /// position gone should be able to say so without first reading how much /// collateral it holds. bool repayAll; /// How much collateral to sell. Less than all of it is a partial close. uint256 collateralAmount; /// The floor on the ENTRY swap — flashed asset into the debt asset. /// /// Zero when there is no entry swap, and required when there is. A /// second swap without a second floor would be a leg of the unwind /// executing at a price nobody agreed to, inside a transaction that /// cannot stop to ask. uint256 entryMinOut; /// The floor on the EXIT sale — collateral into the flashed asset. uint256 minOut; uint16 maxFeeBps; /// Accept a SHORT FILL on the close's sale(s). Default (false) is /// fill-or-kill; true lets the router fill what the price supports, with /// the entryMinOut/minOut floors and CollateralDidNotCoverTheDebt as the /// backstops. Signed, so a compromised agent cannot flip it. bool partialFill; } // WHERE MARKET DEFINITIONS AND v4 RESERVE IDS COME FROM: AdapterCuration, // the protocol's one table per chain, found through the registry. There is // exactly one right answer to "which markets may this account use", and a // second list here would be a second answer. Every read is an EXIT — a close // must work in a retired market, which is the point of retiring close-only. /// The flip in flight, for the duration of one call. /// /// Transient because it must not survive the transaction. A leftover here is /// an authorised callback waiting for anyone who can make Morpho call us. /// /// Internal rather than private only so a test harness can drive the v4 /// arm directly (test/AaveV4CloseShiftLeverage.t.sol); nothing in this /// contract's inheritance writes it but this contract. address internal transient _account; address private transient _morpho; bool private transient _inFlight; /// THE PAYLOAD WE ENCODED, AND NOT MERELY ONE THE LENDER HANDED BACK — H-7 /// of the round-2 audit. See LoanShiftAdapter._payload for the full /// argument; the shape and the exposure are identical here. bytes32 private transient _payload; /// AND ONCE. Nothing prevented a lender invoking the callback twice inside /// one `flashLoan`, and every other gate passes both times. bool private transient _callbackSpent; /// WHAT A CLOSE DID, in a close's own terms. /// /// While this lived in LoanShiftAdapter it emitted `Shifted` with a zero /// destination market, zero supplied and zero borrowed -- a close describing /// itself as a shift that happened to move nothing. Nothing off chain read /// it, so the split is the moment to say the true thing: what was repaid, /// what was sold, what the sale produced, and what went back to the owner. event Closed( address indexed account, bytes32 indexed market, uint256 repaid, uint256 sold, uint256 delivered, uint256 returned ); error ApproveFailed(); /// A token address with no code. A call to one returns success and an empty /// buffer, which the empty-return rule would read as a USDT-class token /// having moved something. See `_okTokenCall` — M-16. error NotAToken(address token); /// A close was asked for on a market where this account holds no collateral. error NothingToClose(); error NoFloor(); error NoRoute(); error NothingToRepay(); error NotFlashLender(address caller); /// A partial close that left the position more levered than it found it. /// See `_requireNoMoreLevered` — M-5. error MoreLeveredAfterUnwind(uint256 debt, uint256 collateral); /// The position venue asked for more than the flash plus a realistic amount /// of interest. See `MAX_DEBT_SLACK_BPS` — H-8. error DebtExceedsFlash(uint256 due, uint256 ceiling); /// The lender handed back a payload that is not the one this contract /// encoded, or invoked the callback twice. See `_payload` — H-7. error PayloadNotOurs(); error CallbackAlreadyRan(); error NotInFlight(); error ReentrantClose(); error DeliveredLessThanFloor(uint256 floor, uint256 delivered); error AdapterRetainedFunds(address asset, uint256 amount); error CollateralDidNotCoverTheDebt(uint256 loan, uint256 delivered); error EntryNeedsItsOwnFloor(); error EntryFloorWithoutAnEntrySwap(); error EntryRouteMismatch(); error BoughtLessThanTheDebt(uint256 needed, uint256 bought); error UnknownLender(uint8 lender); error AaveClosesInFull(); // v4 has no receipt balance to proportion against /// Neither way of asking Aave which aToken represents an asset answered. error NoAToken(address asset); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsLoanCloseAdapter", "1"); } /// Split across two encodes, and not for tidiness. /// /// Fourteen fields in one `abi.encode` runs the stack out with `via_ir` /// pinned off — and it is pinned off because deterministic account addresses /// depend on byte-identical output. The concatenation is what EIP-712 /// requires and is exactly what one encode would have produced: every field /// here is a value type, so each occupies one word and the split is /// invisible in the result. /// Flip, or partly flip, a position. /// /// The route is a separate argument for the same reason it is in /// `SwapAdapter`: it is large, it is untrusted, and signing it would pin a /// plan to a block's liquidity. /// Split across two encodes for the same reason [`digest`] is: fourteen /// fields in one `abi.encode` runs the stack out with `via_ir` pinned off, /// and the concatenation is exactly what one encode would have produced. function closeDigest(Close calldata c) public view returns (bytes32) { return _hashTypedData(keccak256(bytes.concat(_closeHead(c), _closeTail(c)))); } function _closeHead(Close calldata c) internal pure returns (bytes memory) { return abi.encode( CLOSE_TYPEHASH, c.account, c.owner, c.taskId, c.deadline, c.venue, c.router, c.market ); } function _closeTail(Close calldata c) internal pure returns (bytes memory) { return abi.encode( c.flashMarket, c.positionVenue, c.lender, c.repayAmount, c.repayAll, c.collateralAmount, c.entryMinOut, c.minOut, c.maxFeeBps, c.partialFill ); } /// Get out — repay the debt, sell the collateral, keep the difference. /// /// Two shapes, and which one runs depends on whether the debt asset can be /// flash-borrowed at all: /// /// **DIRECT** (`flashMarket == 0`) — flash what is owed, repay, withdraw, /// sell the collateral back into it, hand it over. One swap, one floor, and /// `entryRoute` must be empty. /// /// **VIA** (`flashMarket` names a market) — flash what the lender actually /// has, buy the debt asset with it, repay, withdraw, sell the collateral /// back into the flashed asset, hand it over. Two swaps, two floors. /// /// The second exists because a position can be perfectly solvent and still /// unclosable: a short owes cbBTC, Morpho holds none to lend, and the one /// asset needed for a single transaction is the one that cannot be had. function close( Close calldata c, bytes calldata signature, IAggregationRouter.Path[] calldata entryRoute, IAggregationRouter.Path[] calldata exitRoute ) external { if (_inFlight) revert ReentrantClose(); if (c.repayAmount == 0) revert NothingToRepay(); // v4 cannot proportion (no receipt token). Refuse before the signature // is spent so a partial plan is not burned on a known-impossible close. if (c.lender == uint8(Lender.AaveV4) && !c.repayAll) revert AaveClosesInFull(); _consume(c.account, c.owner, c.taskId, c.deadline, closeDigest(c), signature); _requireOperation(c.account, OP_CLOSE); IMorpho.MarketParams memory m = curationOf(address(registry)).useMarket(c.account, c.market, false); address flashAsset = m.loanToken; if (c.flashMarket != bytes32(0)) { // A LOOKUP, NOT A USE: only the flash token is read, and the account // holds no position in this market, so it must not pin a version of // it. The view answers the pinned version if there is one, else live. flashAsset = curationOf(address(registry)).marketOf(c.account, c.flashMarket).loanToken; } _requireLegs(c, m, flashAsset, entryRoute.length, exitRoute.length); _charge(c.account, m.collateralToken, c.collateralAmount); address morpho = _venueNotRouter(c.account, c.venue); _account = c.account; _morpho = morpho; _inFlight = true; uint256 held = IERC20(flashAsset).balanceOf(address(this)); bytes memory payload = abi.encode(c, entryRoute, exitRoute, m, flashAsset); _payload = keccak256(payload); _callbackSpent = false; IMorphoFlash(morpho).flashLoan(flashAsset, c.repayAmount, payload); _inFlight = false; _payload = bytes32(0); // A DELTA, NOT AN ABSOLUTE BALANCE, and that distinction was a High. // // The adapter's own balance is INVARIANT across a flash: the lender // sends `amount`, the callback pushes exactly `amount` to the account, // the settle pulls exactly `amount` back, the lender takes exactly // `amount`. So `balanceOf(this) != 0` was really "has anybody ever sent // this contract a token", and adapters are shared singletons with no // owner, no sweep, no rescue and no `receive`. One `transfer(adapter, 1)` // — no key, no role, no precondition — bricked every flash operation in // that asset for EVERY account on the chain, permanently, and did it // AFTER `_consume` had already burned the task id. Fifteen transfers // took out `loan_close`, `loan_shift`, `close_aave_*` and // `move_from_aave` including SafeExitAdapter, the emergency // de-risking path, with recovery requiring a redeploy (new bytecode, new // CREATE2 address), a guardian whitelist, the 3-day activation delay and // a re-activation of every account — and the replacement donatable the // block it went live. // // `>` rather than `!=`, so a donation DURING the call is tolerated // rather than weaponised. What the check is for — a path that forgot to // forward what it borrowed — still trips it, because that is a rise. uint256 kept = IERC20(flashAsset).balanceOf(address(this)); if (kept > held) revert AdapterRetainedFunds(flashAsset, kept - held); } /// Each swap must exist exactly when it is needed, with its own route and /// its own floor. /// /// # Both legs are conditional, and for different reasons /// /// The ENTRY swap exists when the flashed asset is not what is owed — a /// short owing an asset the lender does not hold. The EXIT swap exists when /// the collateral is not what has to be handed back — and it does NOT exist /// when they are the same, which is exactly the short case above: flash /// USDC, buy the cbBTC that is owed, repay, and the USDC collateral that /// comes back is already the thing to settle with. /// /// Missing that was worth a real bug: the close tried to sell USDC for USDC, /// which the router happily attempted and the account could not fund. /// /// A route with no floor executes at any price. A floor with no route /// protects a swap that never happens and reads as though somebody believed /// one would. Both are refused. function _requireLegs( Close calldata c, IMorpho.MarketParams memory m, address flashAsset, uint256 entryLegs, uint256 exitLegs ) internal pure { bool needEntry = flashAsset != m.loanToken; bool needExit = m.collateralToken != flashAsset; if (needEntry != (c.entryMinOut != 0)) { if (needEntry) revert EntryNeedsItsOwnFloor(); revert EntryFloorWithoutAnEntrySwap(); } if (needEntry != (entryLegs != 0)) revert EntryRouteMismatch(); if (needExit != (c.minOut != 0)) { if (needExit) revert NoFloor(); revert EntryFloorWithoutAnEntrySwap(); } if (needExit != (exitLegs != 0)) revert NoRoute(); } /// The close leg of the callback. /// /// Its own function rather than another flag through `_run`: the flows /// genuinely differ now — a close may swap twice and never re-enters a /// market — and threading that through would be two shapes pretending to be /// one. What IS shared stays shared: the repay, the withdraw, the measured /// sale and the settlement are the same helpers the shift path uses. function _runClose(uint256 assets, bytes calldata data) internal { ( Close memory c, IAggregationRouter.Path[] memory entryRoute, IAggregationRouter.Path[] memory exitRoute, IMorpho.MarketParams memory m, address flashAsset ) = abi.decode( data, ( Close, IAggregationRouter.Path[], IAggregationRouter.Path[], IMorpho.MarketParams, address ) ); // The account's OWN holding of the flashed asset, before anything moves. // Everything after is a gain over it, so a close never sweeps money the // owner already had into the settlement. uint256 base = IERC20(flashAsset).balanceOf(_account); _transferThis(flashAsset, _account, assets); _closeEntry(c, m, flashAsset, assets, entryRoute); _closeExit(c, m, flashAsset, assets, base, exitRoute); } /// Get hold of what is actually owed, clear it, and free the collateral. /// /// When the flashed asset IS the debt asset this is just a repay. When it is /// not — a short owing an asset the lender does not hold — it buys the debt /// asset first, under its own signed floor. function _closeEntry( Close memory c, IMorpho.MarketParams memory m, address flashAsset, uint256 assets, IAggregationRouter.Path[] memory entryRoute ) internal { uint256 owed = assets; if (flashAsset != m.loanToken) { owed = _swapMeasured( Leg(flashAsset, m.loanToken, assets, c.entryMinOut), c.deadline, c.maxFeeBps, c.partialFill, _venueRouter(_account, c.router), entryRoute ); } _repayAndFree(c, m, owed); } /// Sell the collateral back into whatever has to be handed over, repay the /// loan out of the proceeds, and leave the rest with the owner. function _closeExit( Close memory c, IMorpho.MarketParams memory m, address flashAsset, uint256 assets, uint256 base, IAggregationRouter.Path[] memory exitRoute ) internal { // NO EXIT SWAP when the collateral already IS what has to be handed // back. Selling an asset for itself is not a smaller version of this // step; it is a step that cannot be funded. uint256 delivered = c.collateralAmount; if (m.collateralToken != flashAsset) { delivered = _swapMeasured( Leg(m.collateralToken, flashAsset, c.collateralAmount, c.minOut), c.deadline, c.maxFeeBps, c.partialFill, _venueRouter(_account, c.router), exitRoute ); } // Morpho slack repay can pull idle flash-asset below `base`. uint256 bal = IERC20(flashAsset).balanceOf(_account); uint256 got = bal < base ? 0 : bal - base; if (got < assets) revert CollateralDidNotCoverTheDebt(assets, got); _settle(flashAsset, assets); // WHAT THE OWNER KEEPS, for later steps in this transaction. // // A close sells the collateral, repays the debt, and the difference // stays in the account. That difference is the whole point of closing at // a price, it is not knowable when the plan is signed — it depends on // the sale and on interest accrued to the block that repaid — and it is // exactly what somebody wants to bridge, send or reinvest next. // // `got - assets`, not `delivered`: delivered is what the sale produced // and `assets` is what the debt took. The remainder is the subtraction, // and publishing the sale instead would offer a later step money that // has already gone to Morpho. _publishDelivered(_account, c.taskId, flashAsset, got - assets); emit Closed(_account, c.market, assets, c.collateralAmount, delivered, got - assets); } /// Clear the debt and free the collateral, on whichever protocol holds them. function _repayAndFree(Close memory c, IMorpho.MarketParams memory m, uint256 owed) internal { if (c.lender == uint8(Lender.Morpho)) { _repayAndFreeMorpho(c, m, owed); return; } if (c.lender == uint8(Lender.AaveV3)) { _repayAndFreeAave(c, m, owed); return; } if (c.lender == uint8(Lender.AaveV4)) { _repayAndFreeAaveV4(c, m, owed); return; } revert UnknownLender(c.lender); } /// Morpho: by SHARES when clearing in full, because a debt held in shares /// accrues and a fixed asset amount leaves dust the withdraw then trips on. /// /// The share cost can exceed `owed` (the flash, or the entry-swap output). /// Morpho `transferFrom`s the gap from free loan-token balance. Only /// collateral was capped at the top of `close`, so that gap used to be an /// uncapped spend — undersize the flash, repay the rest from idle USDC. /// Charge it. `owed` stays a ceiling on the FLASH; the cap covers the rest. function _repayAndFreeMorpho(Close memory c, IMorpho.MarketParams memory m, uint256 owed) internal { address account = _account; // THE VENUE THE FIELD NAMES — H-8 of the round-2 audit. // // `Close.positionVenue` is documented as "the contract that actually // holds the debt and the collateral" and is hashed into // `CLOSE_TYPEHASH`. This arm never read it: it used `_morpho`, the FLASH // LENDER, so a signed statement about where the position is was // decoration, and the two Aave arms below (which DO read it) disagreed // with this one about what the field means. // // That mattered because the same address is then asked HOW MUCH TO // APPROVE. `_debtOf` -> `_assetsFor` has no ceiling: a venue answering // `shares = 1e30, totalAssets = 1e30, totalShares = 1` yields // `due ~ 1e60`, and the repay is then made to that same address with the // allowance it chose. Resolving through `_venueOf` puts the answer back // inside the set the owner curated. address morpho = _venueNotRouter(account, c.positionVenue); if (c.repayAll) { // APPROVE WHAT THE SHARES COST, not what was flashed. // // The two are different numbers and the gap is interest. `owed` is // the flash amount, sized when the plan was written; the repay is by // SHARES, and shares are worth more at every later block. A close // planned at 300,100,000 reached the chain owing 300,261,403 and // Morpho's transferFrom was refused for exceeding the allowance — // reported four frames up as the bare words "transferFrom reverted", // which name neither the token nor the shortfall. // // Repaying by assets instead would be the other bug: a fixed amount // leaves dust debt, and dust debt blocks the withdraw. uint256 due = _debtOf(morpho, m, account); // AND A CEILING ON WHAT THE CALLEE MAY ASK FOR — the other half of // H-8. The gap between `owed` and `due` is INTEREST accrued between // the plan being written and this block, which is basis points: the // case that produced this code owed 300,261,403 against a planned // 300,100,000, five of them. `MAX_DEBT_SLACK_BPS` is a hundred times // that, so it cannot bind on an honest close, and it is an absolute // bound on a venue answering with a number it invented. _requireWithinSlack(due, owed); // Interest slack draws idle loan tokens — charge the epoch for it // (DeleverageMorpho already does). if (due > owed) _charge(account, m.loanToken, due - owed); _approveFrom(account, m.loanToken, morpho, due); (, uint128 shares,) = IMorphoFlash(morpho).position(_marketId(m), account); _exec( account, morpho, 0, abi.encodeCall(IMorpho.repay, (m, 0, shares, account, bytes(""))) ); } else { // PRO RATA: clear the same FRACTION of the debt as the collateral // being freed. // // A close that withdraws half the collateral and repays a number // somebody typed leaves a position more levered than the one it // started from — and nobody chose that leverage, it is the residue // of two independent figures. Proportion is the only reading under // which a partial close means what it says: the position that // remains is the same position, smaller. // // It also removes a number the caller cannot know. The debt accrues // between writing the plan and executing it, so a proportional // repay computed off-chain is stale by however long that took. Here // both figures are read in the same call, at the same block. IMorphoFlash(morpho).accrueInterest(m); bytes32 id = _marketId(m); (, uint128 shares, uint128 held) = IMorphoFlash(morpho).position(id, account); if (held == 0) revert NothingToClose(); uint256 part = shares; if (c.collateralAmount < held) { // ROUNDED UP, so the position left behind is healthier than // exactly proportional rather than a wei more levered. The // rounding has to go somewhere and this is the only direction // that cannot make a survivor worse. part = (uint256(shares) * c.collateralAmount + held - 1) / held; } // APPROVE WHAT THE SHARES COST, not the flash amount — the same // lesson as the branch above, and it cost the same test. Rounding // `part` up puts it over `owed` even in the same block. uint256 cost = _assetsFor(morpho, id, part); // The same ceiling as the full-close branch above — H-8. _requireWithinSlack(cost, owed); if (cost > owed) _charge(account, m.loanToken, cost - owed); _approveFrom(account, m.loanToken, morpho, cost); _exec( account, morpho, 0, abi.encodeCall(IMorpho.repay, (m, 0, part, account, bytes(""))) ); } _exec( account, morpho, 0, abi.encodeCall(IMorpho.withdrawCollateral, (m, c.collateralAmount, account, account)) ); // Full-close approves the debt in full but repays by shares, which can // pull a wei less; clear the remainder so no standing allowance to Morpho // survives the close. _requireNoResidual(account, m.loanToken, morpho); } /// `due` must be within `MAX_DEBT_SLACK_BPS` of the flash. See there — H-8. function _requireWithinSlack(uint256 due, uint256 owed) internal pure { uint256 ceiling = owed + (owed * MAX_DEBT_SLACK_BPS) / SLACK_BPS_BASE; if (due > ceiling) revert DebtExceedsFlash(due, ceiling); } /// Aave v3: same proportion as Morpho. `owed` is a ceiling, not the repay. /// /// `repayAll` still means `max`. A partial (`collateralAmount < aToken`) /// without that flag used to repay the flash in full and leave a healthier /// — or, with an undersized flash, a more levered — leftover than anyone /// chose. Ceil so the survivor is never a wei more levered. /// SPLIT IN TWO so the leverage invariant can be read BEFORE the repay /// without running the stack out (via_ir is pinned off — deterministic /// account addresses depend on byte-identical output). Reading it between /// the repay and the withdraw would compare the position against itself /// half-changed, which passes trivially and bounds nothing. function _repayAndFreeAave(Close memory c, IMorpho.MarketParams memory m, uint256 owed) internal { address account = _account; address pool = _venueNotRouter(account, c.positionVenue); // THE WHOLE POSITION, IN THE POOL'S OWN BASE CURRENCY, BEFORE ANYTHING // MOVES — M-5. See `_requireNoMoreLevered`. (uint256 collBefore, uint256 debtBefore,,,,) = IAavePool(pool).getUserAccountData(account); _repayAaveAndFree(c, m, owed, pool); _requireNoMoreLevered(pool, account, collBefore, debtBefore); } function _repayAaveAndFree( Close memory c, IMorpho.MarketParams memory m, uint256 owed, address pool ) internal { address account = _account; uint256 amount = _aaveV3RepayAmount(c, m, pool); uint256 approveAmt = owed; if (amount != AAVE_ALL && amount > owed) { // THE PRO-RATA SHARE IS MORE THAN THE FLASH, and the difference // comes out of the account's own loan tokens — audit #14 of // 2026-10-08. The Morpho arm above bounds that gap by the slack and // charges it to the epoch; this arm approved and spent it with no // charge, so an undersized flash was an unmetered spend of idle // balance. Same bound, same charge. _requireWithinSlack(amount, owed); _charge(account, m.loanToken, amount - owed); approveAmt = amount; } _approveFrom(account, m.loanToken, pool, approveAmt); _exec( account, pool, 0, abi.encodeCall(IAavePool.repay, (m.loanToken, amount, AAVE_VARIABLE, account)) ); // CAPPED AT WHAT THE aTOKEN SAYS, which is the ordinary close and not an // edge case. // // Aave's aToken `balanceOf` is a scaled balance multiplied by the // liquidity index and ROUNDS DOWN: an account that supplied exactly // 10 ether holds 9999999999999999999. "Close everything I put in" names // the number that was supplied, because that is the only number a plan // knows, and Aave refuses the withdraw with // `NotEnoughAvailableUserBalance` — one wei short, every time, on the // case that matters most. The close reverts and the position stays open. // // LoanShiftAdapter._unwindAave has carried this since it was written and // FlashEngine._aaveWithdraw has its own copy; this arm was the one that // withdrew the signed figure raw. // // WRITTEN BACK INTO `c`, deliberately, for the same reason the shift // writes back into `s`: `_closeExit` sizes the sale from // `c.collateralAmount`, so withdrawing less and then selling the // original figure only moves the failure into the router, one step later // and harder to read. // // `close` already charged the signed figure against the epoch. The clamp // only ever moves the number DOWN, so the unused wei is refunded — // otherwise closing a whole position burns budget for a round-down that // never left the account. uint256 have = IERC20(_aTokenOf(pool, m.collateralToken)).balanceOf(account); if (have < c.collateralAmount) { _refund(account, m.collateralToken, c.collateralAmount - have, c.collateralAmount); c.collateralAmount = have; } // The third argument is a RECEIVER, not an onBehalfOf. Copying repay's // shape here is how a withdrawal ends up somewhere nobody chose. _exec( account, pool, 0, abi.encodeCall(IAavePool.withdraw, (m.collateralToken, c.collateralAmount, account)) ); _requireNoResidual(account, m.loanToken, pool); } /// A TOLERANCE, AND WHY IT IS NOT ZERO. The two reads are in the same /// transaction but they are not the same measurement: Aave's `repay` calls /// `updateState` and folds in every second of interest since the reserve was /// last touched, `_proRataCeil` proportions against ONE reserve's debt token /// while this compares base-currency TOTALS, and each conversion rounds at /// eight decimals. A strict inequality rejects an honest pro-rata close on /// arithmetic alone. /// /// Ten bps covers measurement noise (index update, 8-decimal rounding) /// without admitting a 2% relative LTV ratchet per partial unwind. H-8. uint256 internal constant MAX_LEVERAGE_DRIFT_BPS = 10; /// THE POSITION LEFT BEHIND MUST NOT BE MORE LEVERED THAN THE ONE WE /// STARTED FROM — M-5 of the round-2 audit. /// /// `_aaveV3RepayAmount` proportions the repay against the debt of ONE /// RESERVE, and an Aave account is not a set of isolated markets — it is ONE /// CROSS-COLLATERALISED POSITION. With 100 WETH supplied, 50k USDC of debt /// and 50k DAI of debt, `collateralAmount = 50 WETH, repayAll = false` /// repaid 25k USDC and withdrew 50 WETH: 50 WETH now backing 75k of debt, /// health factor pinned near 1.0, liquidatable on any move, and the cap /// charged only the WETH. Aave itself validated it, because `HF >= 1e18` /// held at the instant it was checked. /// /// So the invariant is stated directly, in the pool's OWN base currency, /// across every reserve at once: /// /// debtAfter / collateralAfter <= debtBefore / collateralBefore /// /// cross-multiplied to stay in integers. That is what "a partial close means /// the position that remains is the same position, smaller" actually says, /// and `getUserAccountData` answers it without an oracle, without naming a /// reserve, and whatever number of debts the account carries. /// /// Collateral going to zero is the whole position leaving, which is allowed /// only if the debt went with it. function _requireNoMoreLevered( address pool, address account, uint256 collBefore, uint256 debtBefore ) internal view { (uint256 collAfter, uint256 debtAfter,,,,) = IAavePool(pool).getUserAccountData(account); if (debtAfter == 0) return; if (collAfter == 0) revert MoreLeveredAfterUnwind(debtAfter, collAfter); uint256 was = debtBefore * collAfter; uint256 now_ = debtAfter * collBefore; if (now_ > was + (was * MAX_LEVERAGE_DRIFT_BPS) / 10_000) { revert MoreLeveredAfterUnwind(debtAfter, collAfter); } // AND THE TOLERANCE DOES NOT COMPOUND PAST WHAT A BORROW COULD REACH — // finding 17 of the 2026-09-26 audit. See `_requireRiseWithinCeiling`. _requireAaveRiseWithinCeiling(pool, account, collBefore, debtBefore); } function _aaveV3RepayAmount(Close memory c, IMorpho.MarketParams memory m, address pool) internal view returns (uint256) { if (c.repayAll) return AAVE_ALL; uint256 held = IERC20(_aTokenOf(pool, m.collateralToken)).balanceOf(_account); if (held == 0) revert NothingToClose(); if (c.collateralAmount >= held) return AAVE_ALL; uint256 debt = IERC20(_debtTokenOf(pool, m.loanToken)).balanceOf(_account); return _proRataCeil(debt, c.collateralAmount, held); } /// Ceil so leftover leverage never exceeds the fraction taken. function _proRataCeil(uint256 whole, uint256 take, uint256 total) internal pure returns (uint256) { return (whole * take + total - 1) / total; } /// v4, for a close: repay the amount the flash provided and free the /// collateral. There is no destination, so there is no spoke to choose -- /// `positionVenue` is where the position IS, and that is the only end. function _repayAndFreeAaveV4(Close memory c, IMorpho.MarketParams memory m, uint256 owed) internal { // v4 issues no receipt token this adapter can proportion against (the // spoke's getUserAccountData-shaped call does not name amounts). A // partial would repay `owed` independent of collateralAmount — the // Morpho bug. Refuse it; `repayAll` plus a flash sized to the debt is // the close. if (!c.repayAll) revert AaveClosesInFull(); address account = _account; address spoke = _venueNotRouter(account, c.positionVenue); uint256 loanId = curationOf(address(registry)).useReserve(spoke, m.loanToken, false); uint256 collId = curationOf(address(registry)).useReserve(spoke, m.collateralToken, false); // `repayAll` DOES NOT BECOME A SENTINEL. v3's arm maps it to AAVE_ALL // and lets the pool work the figure out; v4 reads the argument as a // number, so "all of it" here is bounded by what the flash brought. // // WHICH IS WHY IT PROVES NOTHING — finding 3 of the 2026-09-26 audit. // `owed` is the flash and `collateralAmount` the withdrawal, both // signed by the keeper, so "repayAll" with a one-dollar flash repaid a // dollar and withdrew most of the collateral, and nothing looked // afterwards: the spoke's own health floor was the only limit and the // owner's ceiling was not consulted. The v3 arm answers this with M-5, // no more levered than before, and so does this now. A real close ends // at zero debt, which reads as zero and passes; a close that raised the // position's leverage reverts. Not the absolute ceiling: a close must // not ADD leverage at all, even leverage the ceiling would allow. uint256 ltvBefore = LoanHealth.aaveV4LtvBps(spoke, account); _approveFrom(account, m.loanToken, spoke, owed); _exec(account, spoke, 0, abi.encodeCall(IAaveV4Spoke.repay, (loanId, owed, account))); _exec( account, spoke, 0, abi.encodeCall(IAaveV4Spoke.withdraw, (collId, c.collateralAmount, account)) ); _requireV4NoMoreLevered(spoke, account, ltvBefore); _requireNoResidual(account, m.loanToken, spoke); } /// v4's M-5, with the rise its tolerance admits held where a borrow could /// reach — findings 3 and 17 of the 2026-09-26 audit. v4 states no borrow /// limit this can read, so the owner's ceiling alone. function _requireV4NoMoreLevered(address spoke, address account, uint256 ltvBefore) internal view { uint256 ltvAfter = LoanHealth.aaveV4LtvBps(spoke, account); LoanHealth.requireNoMoreLeveredBps(ltvBefore, ltvAfter); _requireRiseWithinCeiling(account, ltvAfter > ltvBefore, ltvAfter, type(uint256).max); } /// One swap's terms, bundled. /// /// A struct rather than four more arguments: nine parameters ran the stack /// out, and `via_ir` is pinned off because deterministic account addresses /// depend on byte-identical output. struct Leg { address tokenIn; address tokenOut; uint256 amountIn; uint256 floor; } /// One swap, measured as a balance delta and held to a floor. /// /// The delta is the only number the router cannot fake, and the floor is the /// only protection against a route computed off chain. Both legs of a VIA /// close get the same treatment; a second swap with weaker protection than /// the first would be the obvious place for a bad route to hide. function _swapMeasured( Leg memory leg, uint256 deadline, uint16 maxFeeBps, bool acceptPartial, address router, IAggregationRouter.Path[] memory route ) internal returns (uint256 delivered) { address account = _account; uint256 before = IERC20(leg.tokenOut).balanceOf(account); _approveFrom(account, leg.tokenIn, router, leg.amountIn); _exec( account, router, 0, abi.encodeCall( IAggregationRouter.swap, ( IAggregationRouter.SwapDescription({ tokenIn: leg.tokenIn, tokenOut: leg.tokenOut, // Never a parameter: the router takes any address and // would happily send a whole position somewhere else. recipient: account, amountIn: leg.amountIn, minAmountOut: leg.floor, deadline: deadline, maxFeeBps: maxFeeBps, partiallyFillable: acceptPartial }), route ) ) ); // The router runs an untrusted, off-chain route and must not keep a // standing allowance a later block could pull; clear it the moment the // swap has taken its input. This is the drain-later vector SwapAdapter's // own _requireNoResidual closes. _requireNoResidual(account, leg.tokenIn, router); delivered = IERC20(leg.tokenOut).balanceOf(account) - before; if (delivered < leg.floor) revert DeliveredLessThanFloor(leg.floor, delivered); } /// Morpho's callback. Everything that makes the flip atomic happens here. /// /// Split across four helpers, and not for tidiness: `via_ir` is pinned OFF /// (deterministic addresses depend on byte-identical output), so the whole /// flip in one body runs the stack out. Each helper reads `_account` and /// `_morpho` from transient storage rather than taking them as arguments, /// which is what keeps the frames small enough to compile. function onMorphoFlashLoan(uint256 assets, bytes calldata data) external { // Two gates, and they answer different questions. The first: is this the // lender we just called. The second: did WE call it — without which any // Morpho instance an account named could hand us a callback we would act // on. if (msg.sender != _morpho) revert NotFlashLender(msg.sender); if (!_inFlight) revert NotInFlight(); // H-7: the payload has to be the one we encoded, and this has to be the // first time we have seen it. See `_payload`. if (keccak256(data) != _payload) revert PayloadNotOurs(); if (_callbackSpent) revert CallbackAlreadyRan(); _callbackSpent = true; _runClose(assets, data); } /// A shift re-enters. Which of the two shapes decides only where the flash /// loan is repaid from. /// Take the borrowed asset to the account, clear the debt, free the /// collateral. The withdraw is only possible because the repay came first, /// which is the whole reason this needs a flash loan at all. /// Clear the debt and free the collateral on whichever protocol HELD the /// position. The flash loan is Morpho's either way; this is only about where /// the position was. /// Aave: repay by AMOUNT, and `max` when clearing in full. /// /// Shorter than Morpho's arm for the reason the close's is: Aave holds debt /// in assets and works `max` out itself, so there is no share arithmetic and /// no dust to leave behind for the withdraw to trip on. /// v4: an asset is a RESERVE ID belonging to the spoke, so both legs look /// theirs up rather than passing an address. /// /// ONE VENUE, BOTH ENDS. A shift carries a single `aaveVenue`, so a v4 shift /// stays inside one spoke. Moving between spokes changes who holds the /// position rather than what the position is, and that is loan.migrate -- /// which carries `fromVenue` and `toVenue` for exactly this reason. /// Refuse a shift whose destination does not match what the owner said it was. /// /// The check runs BEFORE the flash loan, so a mismatch costs nothing and the /// error names the two tokens that disagree rather than leaving somebody to /// work out which of five parameters was wrong. /// A Morpho market's id is the hash of its five parameters, and nothing else. function _marketId(IMorpho.MarketParams memory m) internal pure returns (bytes32) { return keccak256(abi.encode(m)); } /// Sell the collateral and report what ACTUALLY arrived. /// /// Measured as a balance delta, never taken from the router's return value: /// the route is untrusted and the balance is the one number it cannot fake. /// Put the proceeds to work in the new market: what is left after the loan /// becomes collateral, and the new debt is drawn against it. /// Put the position back on whichever protocol it is MOVING TO. /// Aave has no supplyCollateral: a supply IS collateral the first time an /// asset is supplied, so unlike v4 there is nothing to enable afterwards. /// v4 supply is a DEPOSIT until it is enabled as collateral. Borrowing /// against one that was never enabled fails with a health-factor error /// naming a position the protocol does not think exists -- so the enable is /// here, between the supply and the borrow, rather than left to a caller. /// /// This is the difference the v3 arm above names in its own first line: on /// v3 a supply IS collateral the first time and there is nothing to enable. /// What this account's whole debt costs to clear, at THIS block. /// /// `accrueInterest` first, or the totals are the ones from whenever the /// market was last touched and the conversion is short by exactly the /// interest that has accrued since — which is the amount that matters, since /// it is the whole reason the naive figure is wrong. /// /// Rounded UP with Morpho's own virtual shares and assets, because Morpho /// rounds the debt up in its own favour and an approval one wei short is an /// approval that fails. function _debtOf(address morpho, IMorpho.MarketParams memory m, address account) internal returns (uint256) { IMorphoFlash(morpho).accrueInterest(m); bytes32 id = _marketId(m); (, uint128 shares,) = IMorphoFlash(morpho).position(id, account); return _assetsFor(morpho, id, shares); } /// What a number of borrow shares costs to repay, at this market's totals. /// /// Morpho's SharesMathLib, inlined: VIRTUAL_SHARES = 1e6, VIRTUAL_ASSETS = 1. /// The virtual pair is what makes an empty market safe to divide by, so it /// is part of the arithmetic and not a rounding nicety. /// /// Rounded UP, because Morpho rounds the debt up in its own favour and an /// approval one wei short is an approval that fails — as "transferFrom /// reverted", four frames from anything that names the token. /// /// The caller must have accrued interest first, or these totals are the ones /// from whenever the market was last touched and the answer is short by /// exactly the interest since — which is the part that matters. function _assetsFor(address morpho, bytes32 id, uint256 shares) internal view returns (uint256) { (,, uint128 totalAssets, uint128 totalShares,,) = IMorphoFlash(morpho).market(id); uint256 num = shares * (uint256(totalAssets) + 1); uint256 den = uint256(totalShares) + 1e6; return (num + den - 1) / den; } /// Hand the loan back and let Morpho pull it. function _settle(address loanToken, uint256 assets) internal { _exec(_account, loanToken, 0, abi.encodeCall(IERC20.transfer, (address(this), assets))); _approveThis(loanToken, _morpho, assets); } /// WHICH aTOKEN REPRESENTS AN ASSET, both ways Aave answers it. /// /// Copied from `FlashEngine._aTokenOf` rather than shared, for the reason /// LoanShiftAdapter's header already gives about the Aave arms: an /// internal library function is inlined, so each contract pays the bytecode /// anyway, and the EIP-170 limit is what split these contracts up. /// /// TWO PATHS BECAUSE THE THREE CHAINS DISAGREE. `getReserveAToken` is Aave /// 3.2 and Base's pool has it; Arbitrum's classic V3 pool (0x794a6135…) /// does not and answers `getReserveData` instead, whose aToken sits at /// offset 288 of the returned struct. function _aTokenOf(address pool, address asset) internal view returns (address aToken) { (bool ok, bytes memory ret) = pool.staticcall(abi.encodeWithSelector(IAavePool.getReserveAToken.selector, asset)); if (ok && ret.length >= 32) { aToken = abi.decode(ret, (address)); if (aToken != address(0)) return aToken; } (ok, ret) = pool.staticcall( abi.encodeWithSelector(bytes4(keccak256("getReserveData(address)")), asset) ); if (!ok || ret.length < 288) revert NoAToken(asset); assembly { aToken := mload(add(ret, 288)) } } /// Variable-debt token, both ways Aave names it. Word 10 of classic /// `getReserveData` (offset 352) is variableDebtTokenAddress; aToken is /// word 8 at 288. function _debtTokenOf(address pool, address asset) internal view returns (address token) { (bool ok, bytes memory ret) = pool.staticcall( abi.encodeWithSelector(IAavePool.getReserveVariableDebtToken.selector, asset) ); if (ok && ret.length >= 32) { token = abi.decode(ret, (address)); if (token != address(0)) return token; } (ok, ret) = pool.staticcall( abi.encodeWithSelector(bytes4(keccak256("getReserveData(address)")), asset) ); if (!ok || ret.length < 352) revert NoAToken(asset); assembly { token := mload(add(ret, 352)) } } /// Approve from THIS contract (flash settle), not from the account. /// Empty returndata is success: USDT-class tokens return nothing, and /// `if (!IERC20.approve())` ABI-decodes that as a failed call. function _approveThis(address token, address spender, uint256 amount) internal { _okTokenCall(token, abi.encodeCall(IERC20.approve, (spender, amount))); } function _transferThis(address token, address to, uint256 amount) internal { _okTokenCall(token, abi.encodeCall(IERC20.transfer, (to, amount))); } /// EXTCODESIZE FIRST — M-16 of the round-2 audit. /// /// A CALL to an address with no code returns `ok = true` and an empty /// buffer, and the empty-return branch below reads that as "a USDT-class /// token that returns nothing" — so a CODELESS token succeeded at every /// transfer and approve this contract ever made it, having moved nothing. /// `BridgeAdapter:199-212` documents exactly this hazard and defends against /// it; the three copies of this helper did not. function _okTokenCall(address token, bytes memory data) internal { if (token.code.length == 0) revert NotAToken(token); (bool ok, bytes memory ret) = token.call(data); if (!ok) { assembly { revert(add(ret, 32), mload(ret)) } } if (ret.length != 0 && !abi.decode(ret, (bool))) revert ApproveFailed(); } /// Approve from the ACCOUNT, not from here. Split out because inlining it /// four times runs the stack out inside the callback. function _approveFrom(address account, address asset, address spender, uint256 amount) internal { bytes memory ok = _exec(account, asset, 0, abi.encodeCall(IERC20.approve, (spender, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); } /// The sale. Its own function for the same stack reason as `SwapAdapter`'s. /// /// `recipient` is the account and is never a parameter — the router takes any /// address and would happily send a whole position somewhere else. }