// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {AgentRegistry} from "../AgentRegistry.sol"; import {IAggregationRouter} from "./SwapAdapter.sol"; import {FlashEngine, IERC20} from "./lib/FlashEngine.sol"; /// @title SafeExitAdapter — close a Revert position into Aave (facet opTypes 14–16). contract SafeExitAdapter is FlashEngine { bytes32 public constant OP_CLOSE_AAVE_LONG = "close_aave_long"; bytes32 public constant OP_CLOSE_AAVE_SHORT = "close_aave_short"; bytes32 public constant OP_CLOSE_AAVE_SINGLE = "close_aave_single"; bytes32 internal constant ACT_TYPEHASH = keccak256( "Act(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "bytes32 flashVenue,bytes32 revertVenue,bytes32 npmVenue,bytes32 aaveVenue,bytes32 routerVenue," "uint256 tokenId,uint256 repayAmount,uint256 minOut,uint256 minOutRepay,uint256 borrowAmount," "address token0,address token1,address usdc,bool sellToken0," "address amountFrom,bytes32 amountFromTask,uint16 maxLtvBps," "uint256 amount0Min,uint256 amount1Min)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; bytes32 flashVenue; bytes32 revertVenue; bytes32 npmVenue; bytes32 aaveVenue; bytes32 routerVenue; uint256 tokenId; uint256 repayAmount; uint256 minOut; /// Floor on the close_aave_single WETH→USDC leg. minOut only binds the /// first swap (sell→keep). Leaving this unsigned was minOut=1 on an /// untrusted route, so MEV could skim everything above flash repayment. uint256 minOutRepay; uint256 borrowAmount; address token0; address token1; address usdc; bool sellToken0; address amountFrom; bytes32 amountFromTask; uint16 maxLtvBps; /// Uniswap's own floors on the unwind, APPENDED so every field above /// keeps its slot. /// /// Every close in this adapter starts by taking the whole position out /// of the pool, and that decrease ran with `amount0Min = amount1Min = 0` /// — it accepted whatever the pool returned. `close_aave_long` has no /// later swap at all, so `minOut` does not cover it and there was no /// bound anywhere on the operation: a sandwich of the unwind was the /// entire slippage budget. RevertLeverage stopped hardcoding zeros in /// `_viaVault` for exactly this; SafeExit had no fields to stop with. uint256 amount0Min; uint256 amount1Min; } event Closed(address indexed account, bytes32 indexed operation, uint256 tokenId); error UnknownOperation(bytes32 operation); constructor(AgentRegistry registry_) FlashEngine(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsSafeExitAdapter", "1"); } function digest(Act calldata a) public view returns (bytes32) { return _hashTypedData(keccak256(bytes.concat(_head(a), _mid(a), _tail(a)))); } function _head(Act calldata a) internal pure returns (bytes memory) { return abi.encode( ACT_TYPEHASH, a.account, a.owner, a.taskId, a.deadline, a.operation, a.flashVenue, a.revertVenue, a.npmVenue, a.aaveVenue ); } function _mid(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.routerVenue, a.tokenId, a.repayAmount, a.minOut, a.minOutRepay, a.borrowAmount, a.token0 ); } /// SPLIT IN TWO. One encode of fifteen values is a "Stack too deep" with /// via_ir off, and the compiler settings are pinned because every CREATE2 /// address in the system is a function of them. Three concatenated pieces /// hash to exactly the same bytes as one. function _tail(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.token1, a.usdc, a.sellToken0, a.amountFrom, a.amountFromTask, a.maxLtvBps, a.amount0Min, a.amount1Min ); } /// ONE ROUTE PER PAIR, and `close_aave_single` swaps TWO different pairs. /// /// It had one. `_closeSingleSwapSupply` sells the unwanted LP leg into the /// one being kept — sell→keep — and then `_maybeBorrowWethForUsdc` sells /// borrowed WETH into USDC. Those are different token pairs, and a router /// path is a list of pools for ONE pair: handing the second swap the first /// swap's hops asks a WETH/USDC trade to travel a cbBTC/USDC pool. The /// likely outcome is a revert deep inside the aggregator with nothing to /// read; the bad outcome is hops that happen to fill at a price nobody /// chose, with `minOutRepay` as the only thing standing in the way. /// /// LoanCloseAdapter already splits `entryRoute` from `exitRoute` for the /// identical two-swap shape. This is the same split under the same names. /// /// EMPTY IS THE ONE-SWAP CASE. `close_aave_long` swaps nothing and /// `close_aave_short` swaps once, so both pass an empty `exitRoute` and /// nothing reads it. A `close_aave_single` that needs the second leg — /// `borrowAmount != 0` with a non-USDC leg — is refused without one rather /// than quietly reusing the first, which is the whole bug. function act( Act calldata a, bytes calldata signature, IAggregationRouter.Path[] calldata route, IAggregationRouter.Path[] calldata exitRoute ) external { if ( a.operation != OP_CLOSE_AAVE_LONG && a.operation != OP_CLOSE_AAVE_SHORT && a.operation != OP_CLOSE_AAVE_SINGLE ) { revert UnknownOperation(a.operation); } _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); uint256 repay = _amountToSpend(a.account, a.repayAmount, a.amountFrom, a.amountFromTask, a.usdc); address balancer = _venueOf(a.account, a.flashVenue); _account = a.account; _flash(balancer, a.usdc, repay, abi.encode(a, Routes(route, exitRoute), repay)); _account = address(0); emit Closed(a.account, a.operation, a.tokenId); } /// The two routes, carried as ONE value through the flash payload. /// /// A struct rather than two more members of the tuple because `_onFlash` /// has no stack left: decoding a fourth top-level value out of the payload /// is a "Stack too deep" with via_ir off, and the pinned compiler settings /// are not something to change for a parameter list. struct Routes { IAggregationRouter.Path[] entry; IAggregationRouter.Path[] exit; } function _onFlash(address token, uint256 amount, uint256 fee, bytes memory data) internal override { (Act memory a, Routes memory r, uint256 repay) = abi.decode(data, (Act, Routes, uint256)); token; // Bus-resolved flash size. amountFrom plans sign repayAmount=0; unwind // must repay Revert with this, not the literal. a.repayAmount = repay; address account = _account; _pushToAccount(a.usdc, account, amount); if (a.operation == OP_CLOSE_AAVE_LONG) _closeLong(a, amount); else if (a.operation == OP_CLOSE_AAVE_SHORT) _closeShort(a, amount, fee, r.entry); else _closeSingle(a, amount, fee, r); uint256 owed = amount + fee; _pullFromAccount(a.usdc, account, owed); } function _unwindLp(Act memory a) internal returns (address token0, address token1, uint256 got0, uint256 got1) { address account = _account; address vault = _venueOf(account, a.revertVenue); address npm = _venueOf(account, a.npmVenue); uint128 liq; (token0, token1,,,, liq) = _npmPosition(npm, a.tokenId); _revertRepayRemove(account, vault, a.usdc, a.tokenId, a.repayAmount); (got0, got1) = _decreaseCollectAct(a, npm, liq, token0, token1); } /// Fresh frame so `_unwindLp` does not overflow the stack (via_ir off) now /// that the decrease carries the two signed floors as well. /// HOW FAR THE POOL MAY HAVE MOVED FROM ITS OWN TIME-WEIGHTED PRICE FOR AN /// UNWIND TO RUN — the second half of H-9 in the round-2 audit. /// /// H-8's fix landed as a NON-ZERO-ONLY test on `amount0Min`/`amount1Min`, /// and those are numbers B signs, so `amount0Min = amount1Min = 1` passes /// it. Every `close_aave_*` takes the entire liquidity out, and the unwind /// is an INFLOW, so `_spendCap` never fires on it. `_requireLtv` does not /// help: it is a RATIO, so against a 1,000 USDC borrow at an 8000 bps /// ceiling the collateral need only survive down to 1,250 USDC — roughly /// 87.5% of the position is destroyable in one transaction against a 1,000 /// USDC cap charge. A ratio ceiling cannot bound an absolute loss when the /// numerator is fixed by the debt being repaid. /// /// So the bound cannot be B's number. This is the pool's own history /// instead: the attack is "flash-borrow, push the pool to an extreme, unwind /// at a one-wei floor, push back", and a push large enough to matter moves /// SPOT away from the TWAP. 200 ticks is about 2%, which no honest exit is /// near and no profitable sandwich is inside. /// /// A CONSTANT RATHER THAN A FIELD OR A SETTING, for the reason /// `UpgradeTimelock.DELAY` is one: a configurable bound is a bound a /// compromised key sets to zero. `UniswapAdapter.close_at_tick` reaches the /// same anchor through `_twapTick`; this is the same guard for the exit /// paths, which had none. int24 internal constant MAX_UNWIND_TICK_DRIFT = 200; uint32 internal constant TWAP_WINDOW = 300; /// The pool would not answer a TWAP, so there is no anchor to check against /// — and "the venue told us nothing" must not read as "the price is fine". error NoTwap(address pool); /// Spot has been pushed away from the pool's own time-weighted price. See /// `MAX_UNWIND_TICK_DRIFT`. error PoolMoved(int24 spot, int24 twap); function _decreaseCollectAct( Act memory a, address npm, uint128 liq, address token0, address token1 ) internal returns (uint256 got0, uint256 got1) { // See MAX_UNWIND_TICK_DRIFT. Skipped when there is no liquidity to take // out: a fee collect settles at no price, so there is nothing to anchor. if (liq != 0) _requireSettledPool(npm, a.tokenId); // The tokens are PASSED and the deltas are RETURNED: every caller // already has the tokens and needs the deltas, `positions()` is a // twelve-field answer, and this adapter sits within a few hundred bytes // of EIP-170. return _decreaseCollect( _account, npm, a.tokenId, liq, a.deadline, a.amount0Min, a.amount1Min, token0, token1 ); } /// THE POOL THIS POSITION IS IN, AND WHETHER IT HAS JUST BEEN PUSHED. /// /// The pool address is derived rather than configured: the position manager /// names its own factory and the factory names the pool for (token0, token1, /// fee), both of which `positions()` already answers. That keeps this from /// needing a `uniswap_factory` venue, which every live account would have to /// be re-curated with. /// /// Raw staticcalls decoded by offset, for the reason `_position` gives in /// FlashEngine, and every one of them FAILS CLOSED. function _requireSettledPool(address npm, uint256 tokenId) internal view { (address token0, address token1, uint24 fee,,,) = _npmPosition(npm, tokenId); (bool ok, bytes memory ret) = npm.staticcall(abi.encodeWithSignature("factory()")); if (!ok || ret.length < 32) revert NoTwap(npm); address factory = abi.decode(ret, (address)); (ok, ret) = factory.staticcall( abi.encodeWithSignature("getPool(address,address,uint24)", token0, token1, fee) ); if (!ok || ret.length < 32) revert NoTwap(factory); address pool = abi.decode(ret, (address)); if (pool == address(0)) revert NoTwap(factory); // observe(uint32[]) — one offset, a length of two, then the two ages. // The same hand-decoded call UniswapAdapter._twapTick makes. (ok, ret) = pool.staticcall( abi.encodeWithSelector( 0x883bdbfd, uint256(0x20), uint256(2), uint256(TWAP_WINDOW), uint256(0) ) ); if (!ok || ret.length < 0xA0) revert NoTwap(pool); int256 older; int256 newer; assembly { older := mload(add(ret, 0x80)) newer := mload(add(ret, 0xA0)) } int256 delta = newer - older; int256 window = int256(uint256(TWAP_WINDOW)); int256 twap = delta / window; // Solidity truncates toward zero; the pool's own OracleLibrary rounds a // negative average DOWN. if (delta < 0 && delta % window != 0) twap = twap - 1; (ok, ret) = pool.staticcall(abi.encodeWithSignature("slot0()")); if (!ok || ret.length < 64) revert NoTwap(pool); int256 spot; assembly { spot := mload(add(ret, 64)) } spot = int256(int24(spot)); int256 drift = spot > twap ? spot - twap : twap - spot; if (drift > int256(MAX_UNWIND_TICK_DRIFT)) { revert PoolMoved(int24(spot), int24(twap)); } } function _closeLong(Act memory a, uint256 flashAmt) internal { address account = _account; address pool = _venueOf(account, a.aaveVenue); (address token0, address token1, uint256 got0, uint256 got1) = _unwindLp(a); _aaveSupply(account, pool, token0, got0); _aaveSupply(account, pool, token1, got1); // Epoch cap: the new Aave borrow is the asset leaving. LP proceeds // stay as A's collateral — they are not a spend. _charge(account, a.usdc, flashAmt); _aaveBorrow(account, pool, a.usdc, flashAmt); _requireLtv(pool, account, a.maxLtvBps); } function _closeShort( Act memory a, uint256 flashAmt, uint256 fee, IAggregationRouter.Path[] memory route ) internal { if (a.minOut == 0) revert NoFloor(); (address token0, address token1, uint256 got0, uint256 got1) = _unwindLp(a); _supplyBoth(a, token0, token1, got0, got1); _borrowAndSellWeth(a, token0, token1, flashAmt, fee, route); } function _supplyBoth(Act memory a, address token0, address token1, uint256 got0, uint256 got1) internal { address pool = _venueOf(_account, a.aaveVenue); _aaveSupply(_account, pool, token0, got0); _aaveSupply(_account, pool, token1, got1); } function _borrowAndSellWeth( Act memory a, address token0, address token1, uint256 flashAmt, uint256 fee, IAggregationRouter.Path[] memory route ) internal { address account = _account; address pool = _venueOf(account, a.aaveVenue); address router = _venueOf(account, a.routerVenue); address weth = token0 == a.usdc ? token1 : token0; _charge(account, weth, a.borrowAmount); _aaveBorrow(account, pool, weth, a.borrowAmount); SwapSpec memory sw; sw.router = router; sw.tokenIn = weth; sw.tokenOut = a.usdc; // SELL WHAT WAS BORROWED, NOT WHAT THE ACCOUNT HOLDS. // // This read the account's whole WETH balance while the cap two lines // above was charged `a.borrowAmount`. Anything the account already held // — idle WETH, the proceeds of an earlier borrow, a deposit that had // nothing to do with this position — was swept into the sale, priced // only by `minOut`, and none of it counted against the epoch budget the // owner set. The cap and the amount have to be the same number or the // cap bounds nothing. // // The sibling `_maybeBorrowWethForUsdc` already sold `a.borrowAmount`; // these two legs do the same thing and now say so identically. sw.amountIn = a.borrowAmount; sw.minOut = a.minOut; sw.deadline = a.deadline; uint256 delivered = _swap(sw, route); uint256 owed = flashAmt + fee; if (delivered < owed) revert SaleDidNotCoverTheLoan(owed, delivered); _requireLtv(pool, account, a.maxLtvBps); } function _closeSingle(Act memory a, uint256 flashAmt, uint256 fee, Routes memory r) internal { if (a.minOut == 0) revert NoFloor(); address token0; address token1; { uint256 got0; uint256 got1; (token0, token1, got0, got1) = _unwindLp(a); _closeSingleSwapSupply(a, token0, token1, got0, got1, r.entry); } _maybeBorrowWethForUsdc(a, token0, token1, r.exit, flashAmt, fee); _requireLtv(_venueOf(_account, a.aaveVenue), _account, a.maxLtvBps); } function _closeSingleSwapSupply( Act memory a, address token0, address token1, uint256 got0, uint256 got1, IAggregationRouter.Path[] memory route ) internal { address account = _account; address pool = _venueOf(account, a.aaveVenue); address router = _venueOf(account, a.routerVenue); address sell = a.sellToken0 ? token0 : token1; address keep = a.sellToken0 ? token1 : token0; uint256 sellAmt = a.sellToken0 ? got0 : got1; uint256 keepAmt = a.sellToken0 ? got1 : got0; SwapSpec memory sw; sw.router = router; sw.tokenIn = sell; sw.tokenOut = keep; sw.amountIn = sellAmt; sw.minOut = a.minOut; sw.deadline = a.deadline; _charge(account, sell, sellAmt); uint256 bought = _swap(sw, route); _aaveSupply(account, pool, keep, keepAmt + bought); } /// The SECOND pair of `close_aave_single`, with its own route. /// /// `exitRoute` is separate from the sell→keep route above because these are /// different pairs. An empty one reaches `_swap`, which refuses `NoRoute` — /// a plan that needs this leg must supply hops for it rather than inherit /// the first swap's. function _maybeBorrowWethForUsdc( Act memory a, address token0, address token1, IAggregationRouter.Path[] memory exitRoute, uint256 flashAmt, uint256 fee ) internal { if (a.borrowAmount == 0) return; address keep = a.sellToken0 ? token1 : token0; address sell = a.sellToken0 ? token0 : token1; address weth = keep == a.usdc ? sell : keep; if (weth == a.usdc) return; if (a.minOutRepay == 0) revert NoFloor(); uint256 owed = flashAmt + fee; if (a.minOutRepay < owed) revert SaleDidNotCoverTheLoan(owed, a.minOutRepay); address account = _account; _charge(account, weth, a.borrowAmount); _aaveBorrow(account, _venueOf(account, a.aaveVenue), weth, a.borrowAmount); SwapSpec memory sw; sw.router = _venueOf(account, a.routerVenue); sw.tokenIn = weth; sw.tokenOut = a.usdc; sw.amountIn = a.borrowAmount; sw.minOut = a.minOutRepay; sw.deadline = a.deadline; uint256 delivered = _swap(sw, exitRoute); if (delivered < owed) revert SaleDidNotCoverTheLoan(owed, delivered); } }