// 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); /// 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; /// Six aggregate words in the pool's BASE CURRENCY. Only the first two are /// read here: total collateral and total debt, which is the ratio the /// owner's ceiling is stated in. H-1 of the round-2 audit. function getUserAccountData(address user) external view returns (uint256, uint256, uint256, uint256, uint256, uint256); } 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 LoanShiftAdapter 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. enum Mode { Long, Short, Neutral } /// 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. uint256 internal constant BPS = 10_000; uint256 internal constant AAVE_VARIABLE = 2; /// H-8: share-converted Morpho debt may exceed the flash by accrued interest, /// but not by an unbounded idle-balance drain. Same bound as LoanCloseAdapter. uint256 internal constant MAX_DEBT_SLACK_BPS = 500; uint256 internal constant SLACK_BPS_BASE = 10_000; error DebtExceedsFlash(uint256 due, uint256 ceiling); /// 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. bytes32 public constant OP_SHIFT = "loan_shift"; /// 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 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,bool partialFill)" ); 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)" ); /// 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 Shift { address account; address owner; bytes32 taskId; uint256 deadline; /// WHICH Morpho, by name, from the set A installed. bytes32 venue; /// WHICH aggregation router, by name. bytes32 router; /// The market being left and the one being entered, by their curated /// names in AdapterCuration. Never parameters: a market is /// five addresses and an oracle, and accepting them per-call would let a /// plan point a flip at a market its owner never approved. bytes32 fromMarket; bytes32 toMarket; /// How much to borrow, and — unless `repayAll` — how much debt to clear. /// /// **This is what makes a shift partial.** Less than the full debt moves /// part of the position and leaves the rest where it was, which is the /// right shape for someone scaling out of a view rather than reversing /// it in one go. /// /// With `repayAll` set this is a CEILING rather than the amount: the /// flash loan is this size and the repay takes what it needs. Too small /// and Morpho's own pull fails, which is the correct ending. uint256 repayAmount; /// Clear the debt ENTIRELY, by shares rather than by assets. /// /// # Why an asset amount cannot do it /// /// A Morpho debt is held in SHARES and grows with interest between the /// block a plan was written and the block it executes. Repaying a fixed /// asset amount therefore leaves dust — and dust debt is still debt, so /// the withdraw that follows is refused for insufficient collateral and /// the whole flip reverts. /// /// Measured, not reasoned about: a 30,000 USDC borrow repaid with /// exactly 30,000 USDC in the same test returned `30000000000 assets, /// 26748795871423576 shares` and left the position open. Every "full" /// unwind written against an asset amount has this bug, and it only /// appears once time has passed — which is to say, in production. /// /// Repaying by shares clears it exactly, whatever the interest did. The /// flash loan is sized from `repayAmount`, the surplus stays in the /// account, and the settlement accounts for it. bool repayAll; /// How much collateral to withdraw and sell. Signed, rather than derived /// from `repayAmount` by a ratio computed here: the ratio depends on a /// price, and a price this contract computed would be a price nobody /// agreed to. uint256 collateralAmount; /// The floor on the sale. Enforced against the measured balance delta. uint256 minOut; /// What to draw in the new market once the collateral is in place. /// /// Zero is legal and means "move the collateral, take no new debt" — /// a de-risking shift rather than a reversal. uint256 borrowAmount; uint16 maxFeeBps; /// What the owner believes this position will be. See [`Mode`]. /// /// Signed, and checked against the destination market's own tokens. It /// is the difference between a plan that is well-formed and a plan that /// means what its author thought. uint8 mode; /// The Aave pool, by the name the account installed, for whichever side /// of this flip is on Aave. ONE name serves both: v3 has a single pool /// per chain, which is also why an Aave step names no market anywhere /// else here. Ignored when both sides are Morpho, and it must still be /// signed either way — a venue resolved from anything but the signed /// message is a venue the owner did not approve. bytes32 aaveVenue; /// WHICH PROTOCOL HOLDS the position being left, and which will hold the /// one being entered. See [`Lender`]. /// /// Two fields rather than one because the whole point of a flip is that /// the two ends differ, and the useful case is precisely moving BETWEEN /// protocols. Both Morpho is the old behaviour and stays the default at /// zero, so a message built before these existed still means what it /// meant. uint8 fromLender; uint8 toLender; /// Accept a SHORT FILL on the collateral sale. Default (false) is the /// original fill-or-kill: the flash is repaid from the sale's proceeds, so /// a short fill would leave the flip half done. True is the owner's signed /// choice to let the router fill what the price supports; the flip's own /// coverage checks (SaleDidNotCoverTheLoan) and the destination LTV /// ceiling still revert it if the partial sale cannot complete the move. /// 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 that diverges the first time // somebody curated in one place. The FROM leg is read as an exit (a retired // market can still be left); the TO leg as opening exposure. /// 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. /// /// The two existing gates ask WHO CALLED and ARE WE MID-FLASH. Neither asks /// the third question, and the callback `abi.decode`s the ENTIRE operation /// out of `data` — both `MarketParams`, the flash asset, the routes and the /// whole signed struct — while `assets` comes from the lender too. Only /// `_account` was pinned. That the real Morpho echoes `data` verbatim is a /// property of Morpho Blue's implementation, not a check this contract /// performs, and `_requireLegs`, `_requireMode`, `marketOf` and `_spendCap` /// all ran against the OUTER values and were never re-asserted. /// /// The multiplier is that the callback's spend helpers charge no cap at all: /// given a hostile `_venueOf(account, venue)` — reachable through the /// guardian or a compromised curation publisher, not by the agent key alone /// — one valid signed intent became an unbounded swap of the account's whole /// balance into an attacker pool at a one-wei floor, with the zero-amount /// tails all passing and the transaction succeeding. /// /// Two lines, and they close it completely: the hash is written before the /// flash and compared inside the callback, so a lender that alters ANY field /// reverts. Cleared after, so a stale hash cannot authorise a later call. bytes32 private transient _payload; /// AND ONCE, which nothing prevented either. A lender is free to invoke the /// callback twice inside one `flashLoan`, and every gate above would pass /// both times — the same payload, the same account, still in flight. The /// second run would repay a debt that is already zero and swap a balance /// that has already moved. bool private transient _callbackSpent; event Shifted( address indexed account, bytes32 indexed fromMarket, bytes32 indexed toMarket, uint256 repaid, uint256 sold, uint256 delivered, uint256 supplied, uint256 borrowed ); 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(); /// The venue would not say what the position is, so no ceiling can be /// enforced against it. See `LoanHealth.aaveV4LtvBps`. /// A close was asked for on a market where this account holds no collateral. error NoFloor(); error NoRoute(); error NothingToRepay(); error NotFlashLender(); /// A shift that left the position more levered than it found it. See /// `LoanHealth.requireNoMoreLevered` — M-5. /// A partial shift out of Aave v4, which cannot be proportioned. See /// `_unwindAaveV4` — M-5. error AaveShiftsInFull(); /// 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 ReentrantShift(); error MarketsDoNotMeet(); error UnknownMode(); error NotNeutral(); error NeutralNeedsOneAsset(); error NotAReversal(); error SameMarket(); /// A route with nothing to sell. The signed message carried one and this /// move has no sale, so applying it is impossible and ignoring it is worse. error RouteWithNoSale(); /// The same, for a floor: a minOut nothing can be measured against. error FloorWithNoSale(); error DeliveredLessThanFloor(); error SaleDidNotCoverTheLoan(); error AdapterRetainedFunds(); error UnknownLender(); /// Neither way of asking Aave which aToken represents an asset answered. error NoAToken(); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsLoanShiftAdapter", "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. function digest(Shift calldata s) public view returns (bytes32) { return _hashTypedData(keccak256(bytes.concat(_head(s), _tail(s)))); } function _head(Shift calldata s) internal pure returns (bytes memory) { return abi.encode( SHIFT_TYPEHASH, s.account, s.owner, s.taskId, s.deadline, s.venue, s.router, s.fromMarket ); } function _tail(Shift calldata s) internal pure returns (bytes memory) { return abi.encode( s.toMarket, s.repayAmount, s.repayAll, s.collateralAmount, s.minOut, s.borrowAmount, s.maxFeeBps, s.mode, s.aaveVenue, s.fromLender, s.toLender, s.partialFill ); } /// 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. function shift( Shift calldata s, bytes calldata signature, IAggregationRouter.Path[] calldata route ) external { if (_inFlight) revert ReentrantShift(); if (s.repayAmount == 0) revert NothingToRepay(); // THE MARKET ALONE IS NO LONGER ENOUGH, because a same-market move // between two PROTOCOLS is a real thing to want and now has a path: see // the no-sale branch in `_run`. What is left with nothing to do is a // move to the same market at the same lender. if (s.fromMarket == s.toMarket && s.fromLender == s.toLender) revert SameMarket(); _consume(s.account, s.owner, s.taskId, s.deadline, digest(s), signature); _requireOperation(s.account, OP_SHIFT); IMorpho.MarketParams memory from = curationOf(address(registry)).useMarket(s.account, s.fromMarket, false); IMorpho.MarketParams memory to = curationOf(address(registry)).useMarket(s.account, s.toMarket, true); // The loop closes only if the two markets share a token: either what we // sell into is what we owe (FLIP), or the new debt is in the same asset // as the old one (MIGRATE). With neither, the flash loan has nothing to // be repaid from that one swap can produce. if (to.collateralToken != from.loanToken && to.loanToken != from.loanToken) { revert MarketsDoNotMeet(); } _requireMode(s.mode, from, to); // A SALE HAPPENS ONLY WHEN THE COLLATERAL CHANGES, and the route and the // floor belong to the sale. // // Both used to be demanded unconditionally, which is what made a pure // move impossible: it has nothing to sell, so it can offer neither. The // reverse now has to be refused too — a route or a floor in the SIGNED // message with no sale to apply them to would be quietly ignored, and a // floor somebody signed and this contract ignored is worse than no floor // at all. if (from.collateralToken != to.collateralToken) { if (route.length == 0) revert NoRoute(); if (s.minOut == 0) revert NoFloor(); } else { if (route.length != 0) revert RouteWithNoSale(); if (s.minOut != 0) revert FloorWithNoSale(); } _charge(s.account, from.collateralToken, s.collateralAmount); address morpho = _venueNotRouter(s.account, s.venue); _account = s.account; _morpho = morpho; _inFlight = true; uint256 held = IERC20(from.loanToken).balanceOf(address(this)); bytes memory payload = abi.encode(s, route, from, to); _payload = keccak256(payload); _callbackSpent = false; IMorphoFlash(morpho).flashLoan(from.loanToken, s.repayAmount, payload); _inFlight = false; _payload = bytes32(0); // AN ADAPTER THAT KEEPS A BALANCE IS AN ADAPTER SOMEBODY CAN DRAIN. // Checked rather than reasoned about: every path above is supposed to // leave nothing here, and "supposed to" is what this catches. The // comment was right about the risk and wrong about the measurement. // 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(from.loanToken).balanceOf(address(this)); if (kept > held) revert AdapterRetainedFunds(); } /// 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. /// 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. /// 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. /// 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. /// 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. /// 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. /// Clear the debt and free the collateral, on whichever protocol holds them. /// 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. /// Aave: `max` means the whole balance, on both calls, and Aave works the /// figure out itself. That is why this arm is shorter than Morpho's — there /// is no share arithmetic to get wrong. /// /// The market NAME still supplies the pair. Morpho's market carries an /// oracle and an LLTV that Aave ignores, but its two token addresses are /// exactly the collateral and the debt, and reusing them means a plan still /// cannot reach a token nobody curated. /// 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. /// 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. /// 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. /// 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(); 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; _run(assets, data); } function _run(uint256 assets, bytes calldata data) internal { ( Shift memory s, IAggregationRouter.Path[] memory route, IMorpho.MarketParams memory from, IMorpho.MarketParams memory to ) = abi.decode( data, (Shift, IAggregationRouter.Path[], IMorpho.MarketParams, IMorpho.MarketParams) ); // THE ACCOUNT'S OWN HOLDING OF THE BORROWED ASSET, before anything moves. // // Everything after this is measured as a gain over it. Using the // absolute balance instead would sweep whatever USDC the owner already // had into the new collateral, which is the kind of bug that looks like // a rounding difference until it is somebody's savings. uint256 base = IERC20(from.loanToken).balanceOf(_account); _unwind(s, from, assets); // A shift sells into what the DESTINATION takes as collateral — unless // the destination takes what the source already held, in which case // there is nothing to sell and the collateral is already the right // token. `_unwind` has just clamped `s.collateralAmount` to what it // actually withdrew, so that IS what was delivered. uint256 delivered = from.collateralToken == to.collateralToken ? s.collateralAmount : _sellAndMeasure(s, route, from, to.collateralToken); _finish(s, from, to, assets, base, delivered); } /// A shift re-enters. Which of the two shapes decides only where the flash /// loan is repaid from. function _finish( Shift memory s, IMorpho.MarketParams memory from, IMorpho.MarketParams memory to, uint256 assets, uint256 base, uint256 delivered ) internal { uint256 supplied = delivered; if (to.collateralToken == from.loanToken) { // FLIP: out of the proceeds. The gain is the sale PLUS whatever the // flash loan over-provided when `repayAll` took less than it was // handed — both are the account's now, and both belong in the new // collateral once the loan is set aside. uint256 bal = IERC20(from.loanToken).balanceOf(_account); uint256 gained = bal < base ? 0 : bal - base; if (gained < assets) revert SaleDidNotCoverTheLoan(); supplied = gained - assets; } else { // MIGRATE: out of the new borrow, plus any flash-loan surplus. Morpho // slack repay can pull the loan balance below `base`. uint256 bal = IERC20(from.loanToken).balanceOf(_account); uint256 surplus = bal < base ? 0 : bal - base; if (s.borrowAmount + surplus < assets) { revert SaleDidNotCoverTheLoan(); } } _reenter(s, to, supplied); // M-1 OF THE PASHOV AUDIT REFUNDED HERE, and there is nothing to refund // to. A no-sale migrate withdraws collateral and re-supplies it, so the // spend cap had charged for money that never left; the cap was removed // on 2026-09-20 and the charge with it. Nothing else in M-1 applies. _settle(from.loanToken, assets); emit Shifted( _account, s.fromMarket, s.toMarket, assets, s.collateralAmount, delivered, supplied, s.borrowAmount ); } /// 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. function _unwind(Shift memory s, IMorpho.MarketParams memory from, uint256 assets) internal { if (s.fromLender == uint8(Lender.Morpho)) { _unwindMorpho(s, from, assets); return; } if (s.fromLender == uint8(Lender.AaveV3)) { _unwindAave(s, from, assets); return; } if (s.fromLender == uint8(Lender.AaveV4)) { _unwindAaveV4(s, from, assets); return; } revert UnknownLender(); } /// 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. function _unwindAave(Shift memory s, IMorpho.MarketParams memory from, uint256 assets) internal { address account = _account; address pool = _venueNotRouter(account, s.aaveVenue); // THE WHOLE POSITION, BEFORE ANYTHING MOVES — M-5 of the round-2 audit. // See `LoanHealth.requireNoMoreLevered`. (uint256 collBefore, uint256 debtBefore,,,,) = IAavePool(pool).getUserAccountData(account); _transferThis(from.loanToken, account, assets); _approveFrom(account, from.loanToken, pool, assets); // M-5: `repayAll ? AAVE_ALL : assets` REPAYS THE FLASH, AND // `s.collateralAmount` BELOW WITHDRAWS SOMETHING ELSE. Two independent // numbers, which is exactly the bug `LoanCloseAdapter` was rewritten to // remove and which this arm kept — and an Aave account is ONE // CROSS-COLLATERALISED POSITION, so even a correctly proportioned single // reserve says nothing about the position as a whole. // // Not re-derived here: a shift's flash size IS the plan's statement // about how much debt is moving, and rewriting it would change what the // owner signed. The bound is stated as the invariant instead, at the // bottom of this function, in the pool's own base currency and across // every reserve at once. _exec( account, pool, 0, abi.encodeCall( IAavePool.repay, (from.loanToken, s.repayAll ? AAVE_ALL : assets, AAVE_VARIABLE, account) ) ); // CAPPED AT WHAT THE aTOKEN SAYS, and it is a whole-position bug when it // is not. 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. A plan that says "move all of it" // names the number it supplied, because that is the only number it knows, // and Aave refuses it with `NotEnoughAvailableUserBalance` — one wei // short, every time, on the case that matters most. // // FlashEngine has carried this cap since it was written (see // `_aaveWithdraw` there); this contract does not extend FlashEngine, so // it needs its own. Measured on Base at block 50764967, and // contracts/test/FlashloanOverlap.t.sol fails without these two lines. // // WRITTEN BACK INTO `s`, deliberately. `s` is a memory struct and both // `_sellAndMeasure` and `_sell` size the sale from `s.collateralAmount`; // withdrawing less than that and then selling the original figure fails // inside the router instead, one step later and harder to read. // uint256 want = s.collateralAmount; uint256 have = IERC20(_aTokenOf(pool, from.collateralToken)).balanceOf(account); if (have < want) { _refund(account, from.collateralToken, want - have, want); s.collateralAmount = have; } // Third argument is a RECEIVER here, not an onBehalfOf. _exec( account, pool, 0, abi.encodeCall(IAavePool.withdraw, (from.collateralToken, s.collateralAmount, account)) ); // AAVE_ALL repays only the actual debt, which can be less than the // approved `assets`; clear the remainder. _requireNoResidual(account, from.loanToken, pool); // M-5. See `LoanHealth.requireNoMoreLevered`. And the 10 bps it admits // does not compound past what a borrow could reach — finding 18 of the // 2026-09-26 audit. See `_requireRiseWithinCeiling`. LoanHealth.requireNoMoreLevered(pool, account, collBefore, debtBefore); _requireAaveRiseWithinCeiling(pool, account, collBefore, debtBefore); } /// 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. function _unwindAaveV4(Shift memory s, IMorpho.MarketParams memory from, uint256 assets) internal { // NO PARTIALS ON v4 — M-5, and the same refusal `LoanCloseAdapter` makes // for the same reason. The spoke issues no receipt token this adapter // can proportion against, and its `getUserAccountData`-shaped call does // not name amounts, so a partial here would repay `assets` independently // of `s.collateralAmount` with nothing able to check the result. That is // the Morpho bug this file already removed once; `repayAll` with a flash // sized to the debt is the shift. if (!s.repayAll) revert AaveShiftsInFull(); address account = _account; address spoke = _venueNotRouter(account, s.aaveVenue); uint256 loanId = curationOf(address(registry)).useReserve(spoke, from.loanToken, false); uint256 collId = curationOf(address(registry)).useReserve(spoke, from.collateralToken, false); // THE SOURCE MAY NOT END MORE LEVERED — finding 4 of the 2026-09-26 // audit, and the same rule the v3 arm above applies (M-5). `repayAll` // here means only "the flash is the ceiling", and both the flash and // the collateral withdrawn are the keeper's numbers: a one-dollar flash // marked repayAll moved most of the v4 collateral to the destination // and left the debt behind, with only the spoke's health floor under // it. The destination's ceiling was checked; the source never was. A // real shift ends the source at zero debt, which passes. uint256 ltvBefore = LoanHealth.aaveV4LtvBps(spoke, account); _transferThis(from.loanToken, account, assets); _approveFrom(account, from.loanToken, spoke, assets); // NO `max` HERE, and this is the one place v4 differs from v3 in a way // that bites. v3 reads type(uint256).max as "all of it" and works the // figure out itself; v4's repay takes an amount and nothing else. A // sentinel passed here would be read as a NUMBER, and repaying 2^256-1 // reverts on the transfer. So `repayAll` on v4 means "the flash is the // ceiling", exactly as it does on the close. _exec(account, spoke, 0, abi.encodeCall(IAaveV4Spoke.repay, (loanId, assets, account))); // NO aTOKEN CAP, unlike the v3 arm above. That cap exists because v3's // aToken balance rounds DOWN and a plan naming what it supplied is one // wei over; v4 holds the position on the spoke rather than in a rebasing // token, so there is no rounded balance to read and nothing to cap // against. Capping here would need a balance call this interface does // not have, and inventing one to look symmetric would be worse. _exec( account, spoke, 0, abi.encodeCall(IAaveV4Spoke.withdraw, (collId, s.collateralAmount, account)) ); _requireV4NoMoreLevered(spoke, account, ltvBefore); // v4 repay takes an exact amount, so this is normally already zero; // clear unconditionally so the invariant holds if that ever changes. _requireNoResidual(account, from.loanToken, spoke); } /// v4's M-5, with the rise its tolerance admits held where a borrow could /// reach — findings 4 and 18 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); } function _unwindMorpho(Shift memory s, IMorpho.MarketParams memory from, uint256 assets) internal { address account = _account; address morpho = _morpho; uint256 debtBefore; uint256 collBefore; if (!s.repayAll) { debtBefore = LoanHealth.debtOf(morpho, from, account); (,, uint128 coll) = IMorphoFlash(morpho).position(_marketId(from), account); collBefore = coll; } _transferThis(from.loanToken, account, assets); if (s.repayAll) { // APPROVE WHAT THE SHARES COST, not what was flashed. // // The two are different numbers and the gap is INTEREST. `assets` is // the flash, sized when the plan was written; the repay below is by // SHARES, and shares are worth more at every later block. LoanClose // was patched for exactly this after 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. Shift approved the flash size and so // failed the same way: "clear every share" reverts for any position // that accrued between planning and execution, which is all of them. // // Repaying by assets instead would be the other bug: a fixed amount // leaves dust debt, and dust debt blocks the withdrawCollateral // below. // // H-8: bound `due` to the flash + slack so a tiny flash cannot size // an unlimited pull on idle loan-token balance, and charge the gap. uint256 due = LoanHealth.debtOf(morpho, from, account); _requireWithinSlack(due, assets); if (due > assets) _charge(account, from.loanToken, due - assets); _approveFrom(account, from.loanToken, morpho, due); (, uint128 shares,) = IMorphoFlash(morpho).position(_marketId(from), account); _exec( account, morpho, 0, abi.encodeCall(IMorpho.repay, (from, 0, shares, account, bytes(""))) ); } else { _approveFrom(account, from.loanToken, morpho, assets); _exec( account, morpho, 0, abi.encodeCall(IMorpho.repay, (from, assets, 0, account, bytes(""))) ); } _exec( account, morpho, 0, abi.encodeCall(IMorpho.withdrawCollateral, (from, s.collateralAmount, account, account)) ); // repay(shares) in the full path can pull a wei less than the approved // `assets`; clear the remainder so no standing allowance to Morpho lives on. _requireNoResidual(account, from.loanToken, morpho); if (!s.repayAll) { // Cross-multiply debt·collateral (not floored bps) so measurement // truncation cannot ratchet leverage ~12 bps per partial shift. uint256 debtAfter = LoanHealth.debtOf(morpho, from, account); (,, uint128 collAfter) = IMorphoFlash(morpho).position(_marketId(from), account); LoanHealth.requireNoMoreLeveredRaw(debtBefore, collBefore, debtAfter, collAfter); // A RISE is held to the owner's ceiling; a fall never is — audit // #42 of 2026-10-08. This also checked the ABSOLUTE ceiling, so a // position already over it (a price move, a tightened ceiling) // could not be partly shifted out even when the shift lowered its // leverage: the de-risking move refused for the very condition it // reduces. The rule is `_requireRiseWithinCeiling`'s, as on the // Aave arms; the destination is held to its own ceiling on re-entry. _requireRiseWithinCeiling( account, debtAfter * collBefore > debtBefore * collAfter, LoanHealth.morphoLtvBps(morpho, from, account), type(uint256).max ); } } 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); } /// 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. function _requireMode( uint8 mode, IMorpho.MarketParams memory from, IMorpho.MarketParams memory to ) internal pure { bool sameAsset = to.collateralToken == to.loanToken; if (mode == uint8(Mode.Neutral)) { if (!sameAsset) revert NotNeutral(); return; } // Both directional modes refuse a same-asset market. Without this, // "long" would silently accept a market that carries no price exposure // at all — the exact mistake the mode exists to catch, in the other // direction. if (sameAsset) revert NeutralNeedsOneAsset(); if (mode == uint8(Mode.Short)) { // A true reversal, and nothing else. The destination's collateral is // what was owed and its debt is what was held. if (to.collateralToken != from.loanToken || to.loanToken != from.collateralToken) { revert NotAReversal(); } return; } if (mode != uint8(Mode.Long)) revert UnknownMode(); } /// 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. function _sellAndMeasure( Shift memory s, IAggregationRouter.Path[] memory route, IMorpho.MarketParams memory from, address buying ) internal returns (uint256 delivered) { address account = _account; address router = _venueRouter(account, s.router); uint256 before = IERC20(buying).balanceOf(account); _approveFrom(account, from.collateralToken, router, s.collateralAmount); _sell(s, route, from, buying, account); // Untrusted route: clear the router allowance the moment the sale is done, // so no standing transferFrom right on the collateral survives the flip. _requireNoResidual(account, from.collateralToken, router); delivered = IERC20(buying).balanceOf(account) - before; if (delivered < s.minOut) revert DeliveredLessThanFloor(); } /// 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. function _reenter(Shift memory s, IMorpho.MarketParams memory to, uint256 supplied) internal { if (s.toLender == uint8(Lender.Morpho)) { _reenterMorpho(s, to, supplied); return; } if (s.toLender == uint8(Lender.AaveV3)) { _reenterAave(s, to, supplied); return; } if (s.toLender == uint8(Lender.AaveV4)) { _reenterAaveV4(s, to, supplied); return; } revert UnknownLender(); } /// Aave has no supplyCollateral: a supply IS collateral the first time an /// asset is supplied, so unlike v4 there is nothing to enable afterwards. function _reenterAave(Shift memory s, IMorpho.MarketParams memory to, uint256 supplied) internal { address account = _account; address pool = _venueNotRouter(account, s.aaveVenue); if (supplied != 0) { _approveFrom(account, to.collateralToken, pool, supplied); _exec( account, pool, 0, abi.encodeCall(IAavePool.supply, (to.collateralToken, supplied, account, 0)) ); _requireNoResidual(account, to.collateralToken, pool); } // Zero is legal, exactly as on Morpho: a flip that de-risks takes no new // borrowing. onBehalfOf is the LAST argument of borrow and the THIRD of // supply -- see the interface. if (s.borrowAmount != 0) { _exec( account, pool, 0, abi.encodeCall( IAavePool.borrow, (to.loanToken, s.borrowAmount, AAVE_VARIABLE, 0, account) ) ); } // The owner's ceiling. See `LoanHealth.aaveLtvBps` — H-1. _requireLtvCeiling(account, LoanHealth.aaveLtvBps(pool, account)); } /// 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. function _reenterAaveV4(Shift memory s, IMorpho.MarketParams memory to, uint256 supplied) internal { address account = _account; address spoke = _venueNotRouter(account, s.aaveVenue); if (supplied != 0) { uint256 collId = curationOf(address(registry)).useReserve(spoke, to.collateralToken, true); _approveFrom(account, to.collateralToken, spoke, supplied); _exec( account, spoke, 0, abi.encodeCall(IAaveV4Spoke.supply, (collId, supplied, account)) ); _requireNoResidual(account, to.collateralToken, spoke); _exec( account, spoke, 0, abi.encodeCall(IAaveV4Spoke.setUsingAsCollateral, (collId, true, account)) ); } // Zero is legal here for the same reason it is on v3 and Morpho: a shift // that de-risks takes no new borrowing. if (s.borrowAmount != 0) { uint256 loanId = curationOf(address(registry)).useReserve(spoke, to.loanToken, true); _exec( account, spoke, 0, abi.encodeCall(IAaveV4Spoke.borrow, (loanId, s.borrowAmount, account)) ); } // The owner's ceiling. See `LoanHealth.aaveV4LtvBps` — H-1. _requireLtvCeiling(account, LoanHealth.aaveV4LtvBps(spoke, account)); } function _reenterMorpho(Shift memory s, IMorpho.MarketParams memory to, uint256 supplied) internal { address account = _account; address morpho = _morpho; if (supplied != 0) { _approveFrom(account, to.collateralToken, morpho, supplied); _exec( account, morpho, 0, abi.encodeCall(IMorpho.supplyCollateral, (to, supplied, account, bytes(""))) ); _requireNoResidual(account, to.collateralToken, morpho); } // Zero is legal: a shift that de-risks rather than reverses takes no new // borrowing, and forcing a token amount on it would be inventing a view. if (s.borrowAmount != 0) { _exec( account, morpho, 0, abi.encodeCall(IMorpho.borrow, (to, s.borrowAmount, 0, account, account)) ); } // The owner's ceiling. See `LoanHealth.morphoLtvBps` — H-1. _requireLtvCeiling(account, LoanHealth.morphoLtvBps(morpho, to, account)); } /// 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 /// LoanMigrateAdapter'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(); assembly { aToken := mload(add(ret, 288)) } } /// 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(); (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. function _sell( Shift memory s, IAggregationRouter.Path[] memory route, IMorpho.MarketParams memory from, address buying, address account ) internal { _exec( account, _venueRouter(account, s.router), 0, abi.encodeCall( IAggregationRouter.swap, ( IAggregationRouter.SwapDescription({ tokenIn: from.collateralToken, tokenOut: buying, recipient: account, amountIn: s.collateralAmount, minAmountOut: s.minOut, deadline: s.deadline, maxFeeBps: s.maxFeeBps, // The owner's signed choice. False (default) is // fill-or-kill — the flip's original behaviour, since the // loan is repaid from these proceeds. True lets the router // fill what the price supports; SaleDidNotCoverTheLoan and // the destination LTV ceiling are the backstops. partiallyFillable: s.partialFill }), route ) ) ); } }