// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../../BaseAdapter.sol"; import {AgentRegistry} from "../../AgentRegistry.sol"; import {IAggregationRouter} from "../SwapAdapter.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); } interface IERC721 { function ownerOf(uint256 tokenId) external view returns (address); function approve(address to, uint256 tokenId) external; function safeTransferFrom(address from, address to, uint256 tokenId) external; } /// THE FLASH LENDER, and it is Morpho rather than Balancer. /// /// It was Balancer's V2 vault. Both lend flash for nothing -- Balancer's fee has /// been governance-set to zero since launch and Morpho's is zero by design -- /// so the fee was never what decided this. The MONEY was. /// /// Balancer V2 was exploited on 3 November 2025 for about $128m through a /// rounding flaw in its stable pools, its major pools were drained, and Balancer /// Labs announced it would wind down. Measured on forks of the three chains this /// runs on, in USDC available to lend: /// /// chain Balancer V2 Balancer V3 Morpho /// arbitrum 7,476 587 917,691 /// base 53,601 103,629 215,364,851 /// ethereum 281,539 98,533 115,986,963 /// /// Every operation in this engine that still needs a flash loan runs on /// ARBITRUM, where the old lender holds about seven thousand dollars. Balancer /// V3 is not the answer either -- it holds five hundred and eighty seven, and /// its transient unlock/settle interface is a different integration for the /// privilege. /// /// Morpho was already the flash lender for the loan.* adapters, for a reason /// LoanShiftAdapter states plainly: "Morpho lends flash for nothing and Aave /// charges a premium". This is the same lender, now for these too. /// /// THE REPAYMENT IS A PULL, NOT A PUSH, and that is the one real difference. /// Balancer expected a transfer back inside the callback; Morpho takes what it /// is owed with transferFrom afterwards, so the engine APPROVES rather than /// sends. Getting that backwards leaves the loan unrepaid and the whole /// transaction reverts, which is the safe way to be wrong. interface IMorphoFlash { function flashLoan(address token, uint256 assets, bytes calldata data) external; } interface IAavePool { function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; function borrow( address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf ) external; function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) external returns (uint256); function withdraw(address asset, uint256 amount, address to) external returns (uint256); function getUserAccountData(address user) external view returns (uint256, uint256, uint256, uint256, uint256, uint256); /// Aave 3.2. Used so a withdraw can be capped at the aToken balance — /// `balanceOf` rounds down, and withdrawing the exact amount that was /// supplied reverts NotEnoughAvailableUserBalance. function getReserveAToken(address asset) external view returns (address); } interface IRevertVault { function create(uint256 tokenId, address recipient) external; function borrow(uint256 tokenId, uint256 amount) external; function repay(uint256 tokenId, uint256 amount, bool isShare) external returns (uint256 assets, uint256 shares); function remove(uint256 tokenId, address recipient, bytes calldata data) external; function ownerOf(uint256 tokenId) external returns (address); /// THE OWNER'S OWN WAY IN, and the reason several operations here need no /// flash loan at all. /// /// The vault holds the NFT while it is collateral, so the account cannot /// call the position manager -- Uniswap answers "Not approved", because the /// vault grants no approval to anyone. That is what the repay-remove-act- /// redeposit dance was written for. /// /// But the vault does it ITSELF for whoever it records as the loan's owner: /// `tokenOwner[params.tokenId] != msg.sender` reverts Unauthorized, and our /// account IS that owner. Measured on an arbitrum fork against a live /// collateralised position: fees came out, the NFT stayed with the vault and /// the loan stayed open. /// /// A liquidity of zero with the fee amounts at max is "collect the fees and /// change nothing else". Non-zero liquidity withdraws that much, and the /// vault checks the loan is still healthy afterwards -- which the dance had /// to do for itself. struct DecreaseAndCollect { uint256 tokenId; uint128 liquidity; uint256 amount0Min; uint256 amount1Min; uint128 feeAmount0; uint128 feeAmount1; uint256 deadline; address recipient; } function decreaseLiquidityAndCollect(DecreaseAndCollect calldata params) external returns (uint256 amount0, uint256 amount1); function loanInfo(uint256 tokenId) external view returns (uint256 debt, uint256, uint256, uint256, uint256); } interface INPM { struct MintParams { address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 amount0Desired; uint256 amount1Desired; uint256 amount0Min; uint256 amount1Min; address recipient; uint256 deadline; } struct IncreaseLiquidityParams { uint256 tokenId; uint256 amount0Desired; uint256 amount1Desired; uint256 amount0Min; uint256 amount1Min; uint256 deadline; } struct DecreaseLiquidityParams { uint256 tokenId; uint128 liquidity; uint256 amount0Min; uint256 amount1Min; uint256 deadline; } struct CollectParams { uint256 tokenId; address recipient; uint128 amount0Max; uint128 amount1Max; } function mint(MintParams calldata params) external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1); function increaseLiquidity(IncreaseLiquidityParams calldata params) external payable returns (uint128 liquidity, uint256 amount0, uint256 amount1); function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable returns (uint256 amount0, uint256 amount1); function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); function positions(uint256 tokenId) external view returns ( uint96, address, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256, uint256, uint128, uint128 ); } /// @title FlashEngine — Balancer flash + composed `_exec`s. Adapter takes the loan. abstract contract FlashEngine is BaseAdapter { uint256 internal constant AAVE_VARIABLE = 2; uint256 internal constant AAVE_ALL = type(uint256).max; uint256 internal constant BPS = 10_000; address transient _account; address transient _lender; address transient _flashToken; bool transient _inFlight; /// THE PAYLOAD WE ENCODED, AND NOT MERELY ONE THE LENDER HANDED BACK — H-7 /// of the round-2 audit. Every child decodes its whole operation out of /// `data`, so a lender free to rewrite it is a lender free to choose the /// operation. See LoanShiftAdapter._payload for the full argument. bytes32 transient _payload; /// AND ONCE. Nothing prevented a lender invoking the callback twice inside /// one `flashLoan`, and every other gate passes both times. bool transient _callbackSpent; error NotFlashLender(address caller); /// 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 ReentrantFlash(); error AdapterRetainedFunds(address asset, uint256 amount); error ApproveFailed(); error TransferFailed(); /// 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); error NoFloor(); /// An unwind with no floor on a side that actually produced tokens. error NoUnwindFloor(); /// The position manager answered nothing usable for this tokenId. error NoPosition(uint256 tokenId); error NoRoute(); error SameAsset(); error DeliveredLessThanFloor(uint256 floor, uint256 delivered); error SaleDidNotCoverTheLoan(uint256 loan, uint256 delivered); error NothingArrived(address asset); error NotNftOwner(address account, uint256 tokenId); error MissingLtvCap(); // `LtvCapExceeded` now lives on BaseAdapter — see there, H-1. constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _flash(address lender, address token, uint256 amount, bytes memory data) internal { if (_inFlight) revert ReentrantFlash(); _lender = lender; _inFlight = true; _flashToken = token; uint256 held = IERC20(token).balanceOf(address(this)); _payload = keccak256(data); _callbackSpent = false; IMorphoFlash(lender).flashLoan(token, amount, data); _inFlight = false; _lender = address(0); _flashToken = address(0); _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(token).balanceOf(address(this)); if (kept > held) revert AdapterRetainedFunds(token, kept - held); } /// Morpho's callback. The token is not an argument -- it lends one asset per /// call and the caller is the one that named it -- so it is carried in /// transient storage beside the lender, and read back here rather than /// taken from the payload, which is untrusted. function onMorphoFlashLoan(uint256 assets, bytes calldata data) external { if (msg.sender != _lender) 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; address token = _flashToken; // NO FEE. Morpho lends flash for nothing, so the amount owed is the // amount borrowed. The hook still takes a fee argument because the // adapters compute against it and a lender that starts charging would // otherwise be a silent shortfall rather than a compile error. _onFlash(token, assets, 0, data); // APPROVE, DO NOT TRANSFER. Morpho pulls what it is owed after this // returns; Balancer expected it pushed back inside the callback. A // transfer here would send the money and leave the debt, which reverts // the whole transaction -- safe, but for a reason nobody could read. _okTokenCall(token, abi.encodeCall(IERC20.approve, (msg.sender, assets))); } /// A token call that treats EMPTY RETURNDATA as success. /// /// `if (!IERC20(t).approve(...))` compiles to an ABI decode of the return /// buffer, and a token that returns NOTHING -- USDT and the whole class of /// pre-EIP-20-final tokens -- gives the decoder a zero-length buffer, which /// solc reads as `false`. The call succeeded and the adapter reverts anyway. /// That is not theoretical here: it made a USDT-class flash asset unable to /// complete any AaveRevert, RevertLeverage or SafeExit operation, because /// the repayment approve is the last thing the callback does. /// /// A REVERT IS STILL A REVERT. `ok == false` bubbles the callee's own /// returndata rather than being flattened into a local error, so a token /// that genuinely refused says why. Only the "returned nothing at all" case /// is reinterpreted, and a non-empty buffer decoding to false is still a /// failure. /// /// LoanCloseAdapter documents the same rule for its own settle path; this /// is that rule, for the engine the other five adapters share. /// 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. /// /// Checked here rather than at each call site, because the point of one /// helper is that the rule lives in one place. 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 TransferFailed(); } function _onFlash(address token, uint256 amount, uint256 fee, bytes memory data) internal virtual; function _execBatch(address account, address[] memory targets, bytes[] memory calls) internal { uint256 n = targets.length; for (uint256 i; i < n; i++) { _exec(account, targets[i], 0, calls[i]); } } function _approveFrom(address account, address asset, address spender, uint256 amount) internal { if (amount == 0) return; bytes memory ok = _exec(account, asset, 0, abi.encodeCall(IERC20.approve, (spender, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); } function _pushToAccount(address token, address account, uint256 amount) internal { if (amount == 0) return; // Same empty-returndata rule as the repayment approve above: this is the // FIRST thing every callback does, so reading a USDT-class token's empty // buffer as `false` made the flash asset unusable before any operation // had begun. _okTokenCall(token, abi.encodeCall(IERC20.transfer, (account, amount))); } /// MEASURED, NOT ASSUMED — M-16 of the round-2 audit. /// /// This discarded the transfer's return value AND took no balance delta, /// while every sibling in this file checks both. It failed closed only /// because Morpho's own pull fails one frame later — and it STOPS failing /// closed the moment this contract holds donated dust, because `_flash`'s /// post-check is deliberately `kept > held` rather than `kept != 0`. /// /// One `transfer(adapter, 1)` from anybody is enough to arrange that, and /// adapters are shared singletons with no owner, no sweep and no rescue. function _pullFromAccount(address token, address account, uint256 amount) internal { if (amount == 0) return; // THE DELTA ALONE, and it is strictly stronger than the return value: // a token that answers `true` and moves nothing fails this, and one that // answers nothing at all (USDT-class) passes it. Only one check, because // this contract is ON the EIP-170 limit — see M-15. uint256 before = IERC20(token).balanceOf(address(this)); _exec(account, token, 0, abi.encodeCall(IERC20.transfer, (address(this), amount))); if (IERC20(token).balanceOf(address(this)) - before < amount) revert NothingArrived(token); } struct SwapSpec { address router; address tokenIn; address tokenOut; uint256 amountIn; uint256 minOut; uint256 deadline; } function _swap(SwapSpec memory s, IAggregationRouter.Path[] memory route) internal returns (uint256 delivered) { if (s.minOut == 0) revert NoFloor(); if (route.length == 0) revert NoRoute(); if (s.tokenIn == s.tokenOut) revert SameAsset(); address account = _account; // C-2 OF THE ROUND-2 AUDIT: THE DESTINATION TOKEN WAS UNCHECKED HERE. // // `_requireCapRow` was written for exactly this shape and then applied // only to the adapters the finding happened to name — SwapAdapter, // CowPresign, AaveV3 — while this, the one swap primitive every // FlashEngine child routes through, had none. `SafeExitAdapter` passes // `sw.tokenOut = a.usdc`, a raw agent-signed address, on all three legs. // The cap is charged on the INPUT and the loss lands on the OUTPUT, so // without a row for the output an agent that deploys its own token and // seeds a pool converts a cap of X into X of extraction per epoch // rather than a slippage fraction of it. See BaseAdapter._requireCapRow. // // CHECKED HERE RATHER THAN AT EACH CALL SITE deliberately: this is the // choke point, so a future leg added to any child inherits the rule // instead of having to remember it. That the vector is not live today // is an accident of `a.usdc` also being the Morpho flash asset — which // is the "fails closed only by configuration" shape the last audit // filed M-15 about. _requireCuratedAsset(s.tokenOut); uint256 before = IERC20(s.tokenOut).balanceOf(account); _approveFrom(account, s.tokenIn, s.router, s.amountIn); IAggregationRouter.SwapDescription memory d; d.tokenIn = s.tokenIn; d.tokenOut = s.tokenOut; d.recipient = account; d.amountIn = s.amountIn; d.minAmountOut = s.minOut; d.deadline = s.deadline; d.maxFeeBps = 50; d.partiallyFillable = false; _exec(account, s.router, 0, abi.encodeCall(IAggregationRouter.swap, (d, route))); _requireNoResidual(account, s.tokenIn, s.router); delivered = IERC20(s.tokenOut).balanceOf(account) - before; if (delivered < s.minOut) revert DeliveredLessThanFloor(s.minOut, delivered); } /// The six fields of `positions()` anybody here reads. /// /// A RAW STATICCALL RATHER THAN A TYPED ONE, and the reason is EIP-170. /// `positions` returns TWELVE values and solc's generated decoder — with a /// range check per narrow type — is a few hundred bytes in every adapter /// that inherits this, two of which sit within a few hundred bytes of the /// limit. All twelve are STATIC, so each lives at a fixed word: /// /// 0 nonce 1 operator 2 token0 3 token1 4 fee 5 tickLower /// 6 tickUpper 7 liquidity 8/9 feeGrowth 10/11 tokensOwed /// /// The truncating casts are deliberate and are what the decoder's range /// checks would otherwise enforce: a venue that answered with dirt in the /// high bits of `fee` gets it dropped rather than reverting. The npm is an /// owner-installed venue (`_venueOf`), not something an intent can name. function _npmPosition(address npm, uint256 tokenId) internal view returns ( address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity ) { (bool ok, bytes memory ret) = npm.staticcall(abi.encodeWithSelector(0x99fbab88, tokenId)); if (!ok || ret.length < 0x180) revert NoPosition(tokenId); uint256 w2; uint256 w3; uint256 w4; assembly { w2 := mload(add(ret, 0x60)) w3 := mload(add(ret, 0x80)) w4 := mload(add(ret, 0xA0)) } token0 = address(uint160(w2)); token1 = address(uint160(w3)); fee = uint24(w4); int256 lo; int256 hi; uint256 liq; assembly { lo := mload(add(ret, 0xC0)) hi := mload(add(ret, 0xE0)) liq := mload(add(ret, 0x100)) } tickLower = int24(lo); tickUpper = int24(hi); liquidity = uint128(liq); } /// EVERY UNWIND CARRIES A FLOOR ON EVERY SIDE IT PRODUCES. /// /// `close_aave_long` and `move_to_aave` take the ENTIRE liquidity out of a /// position, and neither `amount0Min` nor `amount1Min` was checked /// anywhere — while the sibling SWAP floors in the same signed struct all /// revert `NoFloor()` on a zero `minOut`. So the loss ceiling on the leg /// that unwinds the whole position was the pool's depth, not the owner's /// daily budget: flash-borrow, push the tick, unwind at that price, push it /// back. `test_CloseLongHonoursTheSignedUnwindFloors` tested only an /// unreachably HIGH floor, never a zero one. /// /// CHECKED AFTER, AGAINST WHAT ARRIVED, rather than as "both must be /// non-zero" up front: a position wholly on one side of the price /// legitimately produces one token and nothing of the other, and demanding /// a floor on the empty side would make those positions unclosable. The /// same rule and the same reasoning as UniswapAdapter._decreaseCollect. function _decreaseCollect( address account, address npm, uint256 tokenId, uint128 liquidity, uint256 deadline, uint256 amount0Min, uint256 amount1Min, address token0, address token1 ) internal returns (uint256 got0, uint256 got1) { got0 = IERC20(token0).balanceOf(account); got1 = IERC20(token1).balanceOf(account); (uint256 dec0, uint256 dec1) = _decreaseCollectCalls(account, npm, tokenId, liquidity, deadline, amount0Min, amount1Min); // MEASURED HERE AND HANDED BACK, rather than measured again by every // caller. The floor check below needs the deltas, and all five callers // needed the same two numbers immediately afterwards — so they were // being read twice, at four extra staticcalls a call and a few hundred // bytes of duplicated code in adapters that are within a few hundred // bytes of EIP-170. got0 = IERC20(token0).balanceOf(account) - got0; got1 = IERC20(token1).balanceOf(account) - got1; // Liquidity of zero is a fee collect wearing a decrease's shape: fees // are whatever accrued and settle at no price, so there is no floor to // demand. if (liquidity == 0) return (got0, got1); // MEASURED AGAINST THE DECREASE'S OWN RETURN, NOT THE COMBINED DELTA — // M-11 OF THE ROUND-2 AUDIT, WHICH DEADLOCKED THE EMERGENCY EXIT. // // `got0`/`got1` span `decreaseLiquidity` AND `collect`, so they include // FEES, while the floors are enforced by the position manager against // the decrease alone. For a position now wholly on one side of the price // with fees owed on the empty side, both branches reverted: // // amount1Min = 1 -> the NPM refuses, "Price slippage check": the // decrease genuinely produces zero of token1. // amount1Min = 0 -> `collect` sweeps `tokensOwed1`, `got1 != 0`, // and this check fired `NoUnwindFloor`. // // All three `close_aave_*` operations route through here, so the // de-risking path was unusable for that whole class of position — // exactly when the price has moved through the range, which is when it // is needed. // // The decrease's own two return values are the sides that had a PRICE. // Fees are whatever accrued and settle at no price, which is the same // reason `liquidity == 0` returns above. // // ONE REVERT SITE, and a no-argument error, because these two adapters // are within a few hundred bytes of EIP-170 and each distinct revert // with an argument is an ABI encode in the bytecode. Which side was // unbounded is recoverable from the call's own `amount0Min` / // `amount1Min`, so the error carries nothing the caller does not have. if ((dec0 != 0 && amount0Min == 0) || (dec1 != 0 && amount1Min == 0)) { revert NoUnwindFloor(); } } /// The two calls, in their own frame: seven parameters plus the balance /// snapshots above overrun the stack in one body with `via_ir` pinned off. function _decreaseCollectCalls( address account, address npm, uint256 tokenId, uint128 liquidity, uint256 deadline, uint256 amount0Min, uint256 amount1Min ) private returns (uint256 dec0, uint256 dec1) { bytes memory ret = _exec( account, npm, 0, abi.encodeCall( INPM.decreaseLiquidity, (INPM.DecreaseLiquidityParams({ tokenId: tokenId, liquidity: liquidity, amount0Min: amount0Min, amount1Min: amount1Min, deadline: deadline })) ) ); // The decrease's OWN output, which is what the floors bound. See the // caller — M-11. if (ret.length >= 64) (dec0, dec1) = abi.decode(ret, (uint256, uint256)); _exec( account, npm, 0, abi.encodeCall( INPM.collect, (INPM.CollectParams({ tokenId: tokenId, recipient: account, amount0Max: type(uint128).max, amount1Max: type(uint128).max })) ) ); } function _aaveSupply(address account, address pool, address asset, uint256 amount) internal { if (amount == 0) return; _approveFrom(account, asset, pool, amount); _exec( account, pool, 0, abi.encodeCall(IAavePool.supply, (asset, amount, account, uint16(0))) ); _requireNoResidual(account, asset, pool); } function _aaveBorrow(address account, address pool, address asset, uint256 amount) internal { if (amount == 0) return; uint256 before = IERC20(asset).balanceOf(account); _exec( account, pool, 0, abi.encodeCall(IAavePool.borrow, (asset, amount, AAVE_VARIABLE, uint16(0), account)) ); if (IERC20(asset).balanceOf(account) <= before) revert NothingArrived(asset); } function _aaveRepay(address account, address pool, address asset, uint256 amount) internal { // LoanShiftAdapter: approve the cash on the account, never uint256.max. // Aave treats max as "all of the debt" on repay; approving max is a // standing allowance, and a transferFrom of max would revert. uint256 grant = amount == AAVE_ALL ? IERC20(asset).balanceOf(account) : amount; _approveFrom(account, asset, pool, grant); _exec( account, pool, 0, abi.encodeCall(IAavePool.repay, (asset, amount, AAVE_VARIABLE, account)) ); _requireNoResidual(account, asset, pool); } 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; } // Classic V3 (Arbitrum 0x794a…) has getReserveData, not getReserveAToken. (ok, ret) = pool.staticcall( abi.encodeWithSelector(bytes4(keccak256("getReserveData(address)")), asset) ); if (!ok || ret.length < 288) revert NothingArrived(asset); assembly { aToken := mload(add(ret, 288)) } } function _aaveWithdraw(address account, address pool, address asset, uint256 amount) internal { if (amount != AAVE_ALL) { uint256 have = IERC20(_aTokenOf(pool, asset)).balanceOf(account); if (have < amount) amount = have; } uint256 before = IERC20(asset).balanceOf(account); _exec(account, pool, 0, abi.encodeCall(IAavePool.withdraw, (asset, amount, account))); if (IERC20(asset).balanceOf(account) <= before) revert NothingArrived(asset); } function _ltvBps(address pool, address account) internal view returns (uint256) { (uint256 collateral, uint256 debt,,,,) = IAavePool(pool).getUserAccountData(account); if (collateral == 0) return debt == 0 ? 0 : type(uint256).max; return (debt * BPS + collateral - 1) / collateral; } /// THE TIGHTER OF THE TWO. `capBps` is the field B signs, and until /// 2026-09-10 it was the only bound — `type(uint16).max` compared against /// the venue's own answer, so the OWNER'S ceiling did not exist. A /// liquidation costs the owner the 5-10% penalty with zero token outflow /// and therefore zero cap charge, which is the loss the owner's bound is /// for. See AgentRegistry.ltvCeilingOf. function _requireLtv(address pool, address account, uint16 capBps) internal view { if (capBps == 0) revert MissingLtvCap(); uint16 cap = _effectiveLtvCap(account, capBps); uint256 ltv = _ltvBps(pool, account); if (ltv > cap) revert LtvCapExceeded(ltv, cap); } /// THE FLOORS ARE PART OF THE SPEC, not something the mint invents. /// /// They were absent, and every caller signs them: RevertLeverage's /// mint_levered / split / rebalance / reposition and AaveRevert's /// move_from_aave all hash `amount0Min` and `amount1Min` into the Act the /// owner's agent signs, and every one of them then reached a `MintParams` /// whose two floors were the struct's zero value. A mint with zero floors /// accepts any deposit ratio the pool happens to offer, so the remint at the /// end of an unwind — the step that puts the whole position back — was the /// one leg of these operations an attacker could move the price under. /// Signing a bound and discarding it is worse than not offering one: the /// plan believes it is protected. struct MintSpec { address npm; address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 amount0; uint256 amount1; uint256 amount0Min; uint256 amount1Min; uint256 deadline; } function _mintNpm(MintSpec memory m) internal returns (uint256 tokenId, uint256 used0, uint256 used1) { INPM.MintParams memory p; p.token0 = m.token0; p.token1 = m.token1; p.fee = m.fee; p.tickLower = m.tickLower; p.tickUpper = m.tickUpper; p.amount0Desired = m.amount0; p.amount1Desired = m.amount1; p.amount0Min = m.amount0Min; p.amount1Min = m.amount1Min; p.recipient = _account; p.deadline = m.deadline; bytes memory ret = _exec(_account, m.npm, 0, abi.encodeCall(INPM.mint, (p))); (tokenId,, used0, used1) = abi.decode(ret, (uint256, uint128, uint256, uint256)); } /// NPM mint/increase take a RATIO of the two desired amounts, so one side is /// routinely left over. The budget was charged both ceilings; the leftover /// never left the account and must not burn the epoch. function _refundIfLess(address account, address asset, uint256 desired, uint256 used) internal { if (used < desired) _refund(account, asset, desired - used, desired); } function _revertCreateBorrow( address account, address npm, address vault, address usdc, uint256 tokenId, uint256 amount ) internal { _exec(account, npm, 0, abi.encodeCall(IERC721.approve, (vault, tokenId))); _exec(account, vault, 0, abi.encodeCall(IRevertVault.create, (tokenId, account))); if (amount == 0) return; uint256 before = IERC20(usdc).balanceOf(account); _exec(account, vault, 0, abi.encodeCall(IRevertVault.borrow, (tokenId, amount))); if (IERC20(usdc).balanceOf(account) <= before) revert NothingArrived(usdc); _requireRevertLtv(vault, tokenId); } /// THE OWNER'S CEILING ON A REVERT FINANCE LOAN — H-1 of the round-2 audit. /// /// `RevertLeverageAdapter` signs `maxLtvBps` into its digest and reads it /// nowhere; `RevertFiAdapter` has no LTV field at all. The audit noted that /// `IRevertVault.loanInfo` was already DECLARED here with zero callers, /// which is the shape of a bound somebody meant to enforce — so this is it. /// /// `loanInfo` answers `(debt, fullValue, collateralValue, liquidationCost, /// liquidationValue)`. `fullValue` is what the NFT is worth at the vault's /// own oracle and `debt` is what is owed against it, so the ratio is the /// LTV in exactly the units `AgentRegistry.ltvCeilingOf` is stated in. /// `collateralValue` is `fullValue` already multiplied by the vault's own /// collateral factor — the venue's bound, not the owner's — which is why /// the first of the two is the right denominator here. /// /// CHECKED AT THE CHOKE POINT, so every present and future caller inherits /// it: `_revertCreateBorrow` is the one place this repo borrows from a /// Revert vault outside `RevertFiAdapter`, which does its own. function _requireRevertLtv(address vault, uint256 tokenId) internal view { (uint256 debt, uint256 fullValue,,,) = IRevertVault(vault).loanInfo(tokenId); if (debt == 0) return; // A loan against a position the vault prices at nothing is not a ratio // this can bound, and it is not one that should pass either. _requireLtvCeiling( _account, fullValue == 0 ? type(uint256).max : (debt * BPS + fullValue - 1) / fullValue ); } function _revertRepayRemove( address account, address vault, address usdc, uint256 tokenId, uint256 amount ) internal { if (amount > 0) { _approveFrom(account, usdc, vault, amount); _exec(account, vault, 0, abi.encodeCall(IRevertVault.repay, (tokenId, amount, false))); _requireNoResidual(account, usdc, vault); } _exec(account, vault, 0, abi.encodeCall(IRevertVault.remove, (tokenId, account, bytes("")))); } }