// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {AgentRegistry} from "../AgentRegistry.sol"; import {IAggregationRouter} from "./SwapAdapter.sol"; import {FlashEngine, IERC20, IERC721, INPM, IRevertVault} from "./lib/FlashEngine.sol"; /// @title RevertLeverageAdapter — levered Revert Uni LP (Diamond FlashloanFacet ops 0–9, 11–12). contract RevertLeverageAdapter is FlashEngine { /// INTERNAL, UNLIKE EVERY OTHER ADAPTER'S, AND ONLY BECAUSE OF EIP-170. /// /// M-15 of the round-2 audit: this contract sits ON the limit. Nine public /// `bytes32` constants are nine dispatch entries and nine getters — 426 /// bytes measured — and nothing outside the tests ever read them: the Go /// side names operations as strings (`internal/adapters/registry.go`) and /// the install payload carries the strings themselves. The coupling is still /// tested, because a typo in the literal is a test that fails. /// /// If this contract is ever split — which the audit recommends, and which /// would be the better answer than trimming — these go back to `public` with /// the rest. bytes32 internal constant OP_COLLECT_LEVERED = "collect_levered"; bytes32 internal constant OP_INCREASE = "increase"; bytes32 internal constant OP_DECREASE = "decrease"; bytes32 internal constant OP_CLOSE = "close"; bytes32 internal constant OP_INCREASE_LEV = "increase_lev"; bytes32 internal constant OP_MINT_LEVERED = "mint_levered"; // split, rebalance and reposition moved to RevertRangeAdapter (EIP-170, // audit M-15). They share this contract's Act struct and typehash there. 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 amount,uint256 amount0,uint256 amount1,uint256 amount0Min,uint256 amount1Min," "address token0,address token1,uint24 fee,int24 tickLower,int24 tickUpper,uint16 bps," "uint256 minOut,uint256 borrowAmount,uint256 collateralAmount,address asset," "address amountFrom,bytes32 amountFromTask,uint16 maxLtvBps,int24 tick)" ); 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 amount; uint256 amount0; uint256 amount1; uint256 amount0Min; uint256 amount1Min; address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint16 bps; uint256 minOut; uint256 borrowAmount; uint256 collateralAmount; address asset; address amountFrom; bytes32 amountFromTask; uint16 maxLtvBps; // APPENDED so every field above keeps its slot. Only `split` reads it: // where the two children meet. See _splitMint. // // M-1 OF THE ROUND-2 AUDIT: IT WAS EXECUTED AND NOT SIGNED. The comment // above was written when the field was added and the TYPEHASH was never // extended to match — 29 names against 30 members — so `digest()` // returned the same value for `tick = 100` and `tick = -887220`, and // the prior audit's claim that "all 21 typehashes match their structs // field-by-field in order, count and type" was wrong about this one. // // What that bought: for OP_SPLIT the relayer chose where the two // children meet, bounded only by `_checkSplitTick`, and both children // mint with zero floors and are each levered by // `_revertCreateBorrow(a.amount0/a.amount1)`. Mitigated in production // only because `msg.sender == agent` skips the adapter signature // entirely and `Agent.CALL_TYPEHASH` binds `keccak256(data)` — which // does cover it. The gap was live on the EIP-712 path this contract // still ships, and that path is the ONLY bound whenever // `msg.sender != agent`. int24 tick; } event Acted(address indexed account, bytes32 indexed operation, uint256 tokenId); error UnknownOperation(bytes32 operation); /// A decrease whose share of the position rounds to zero liquidity, which /// the vault would perform as a fee collection. error ZeroDecrease(uint256 tokenId, uint128 liquidity, uint16 bps); /// `increase_lev`'s flash asset is neither of the position's tokens, so /// there is no leg for the flash to fund. See `_increaseAny`. error FlashAssetNotInPosition(address asset, address token0, address token1); /// The levered leg is larger than the flash it comes out of. error LeverExceedsFlash(uint256 leg, uint256 flash); constructor(AgentRegistry registry_) FlashEngine(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsRevertLeverageAdapter", "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.amount, a.amount0, a.amount1, a.amount0Min, a.amount1Min, a.token0, a.token1, a.fee ); } function _tail(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.tickLower, a.tickUpper, a.bps, a.minOut, a.borrowAmount, a.collateralAmount, a.asset, a.amountFrom, a.amountFromTask, a.maxLtvBps, a.tick ); } function act(Act calldata a, bytes calldata signature, IAggregationRouter.Path[] calldata route) external { _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); // TWO OF THESE NEED NO FLASH LOAN, and never did on THIS vault. // // The dance the rest of this contract performs -- flash-borrow, repay // the loan, take the NFT back, act on it, re-deposit, re-borrow, repay // the flash -- exists because the vault holds the NFT while it is // collateral, so the account cannot call the position manager. That is // true: Uniswap answers "Not approved". // // It is also beside the point for collecting fees and for reducing // liquidity, because the vault does BOTH ITSELF for the account it // records as the loan's owner. Measured on an arbitrum fork against a // live collateralised position: decreaseLiquidityAndCollect returned // fees, the NFT stayed with the vault, and the loan stayed open. The // same call from any other address is Unauthorized(). // // So these two take the vault's own door: one call instead of seven, no // Balancer fee, and no window in which the position is uncollateralised. // AND ONLY THESE TWO. Checked against the deployed vault rather than // assumed, because the obvious next question is "why not the others": // // create(uint256,address) PRESENT // borrow(uint256,uint256) PRESENT // repay(uint256,uint256,bool) PRESENT // decreaseLiquidityAndCollect(...) PRESENT // remove(uint256,address,bytes) PRESENT // increaseLiquidity / addLiquidity / … ABSENT, under any name // // So anything that ADDS liquidity has no owner path here at all -- // increase, add_leverage and open_levered -- and anything that RESHAPES // one mints a replacement NFT, which only a whitelisted transformer may // do: split, rebalance, reposition. `transform` exists and refuses an // arbitrary transformer with TransformNotAllowed(), and that allow-list // is Revert's to control. // // CLOSE LOOKS LIKE IT SHOULD FIT AND DOES NOT. Repaying needs cash, the // only cash is inside the collateral, and decreaseLiquidityAndCollect // checks the loan stays healthy -- so the withdrawal large enough to // repay is exactly the one it blocks. That circularity is what a flash // loan is for. Collecting fees never had it. if (a.operation == OP_COLLECT_LEVERED || a.operation == OP_DECREASE) { _account = a.account; _viaVault(a); } else { uint256 flashAmt = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, a.asset); address balancer = _venueOf(a.account, a.flashVenue); _account = a.account; _flash(balancer, a.asset, flashAmt, abi.encode(a, route, flashAmt)); } // ONE TEARDOWN AND ONE EVENT for both shapes. They were duplicated // across an early return, which is two copies of the same three lines // in a contract that sits ON the EIP-170 limit — M-15. _account = address(0); emit Acted(a.account, a.operation, a.tokenId); } function _onFlash(address token, uint256 amount, uint256 fee, bytes memory data) internal override { (Act memory a, IAggregationRouter.Path[] memory route, uint256 flashAmt) = abi.decode(data, (Act, IAggregationRouter.Path[], uint256)); token; fee; address account = _account; _pushToAccount(a.asset, account, amount); // COLLECT AND DECREASE ARE NOT HERE. They return from `act` before the // flash loan is taken -- the vault does both for the loan's owner, so // there is nothing to borrow, unwind or put back. See _viaVault. uint256 owed = amount + fee; if (a.operation == OP_INCREASE) _increaseAny(a, flashAmt, false); else if (a.operation == OP_CLOSE) _close(a, flashAmt, owed); else if (a.operation == OP_INCREASE_LEV) _increaseAny(a, flashAmt, true); else if (a.operation == OP_MINT_LEVERED) _mintLevered(a, flashAmt); // split, rebalance and reposition are RevertRangeAdapter's now. else revert UnknownOperation(a.operation); _pullFromAccount(a.asset, account, owed); route; } /// Collect fees, or take liquidity out, THROUGH THE VAULT. /// /// Zero liquidity with the fee caps at max is "collect and change nothing /// else". A decrease names its share in bps of what the position holds, the /// same figure the flash path used, and the vault checks the loan is still /// healthy after -- which the flash path had to check for itself, one step /// later and after the money had already moved. /// /// The recipient is the ACCOUNT and is never a signed field, for the reason /// every other adapter here says: a router or a vault takes any address, and /// this is where a whole position would go if it took one from a message. /// /// amount0Min/amount1Min are the signed Uni floors. Hardcoding zeros here /// made a decrease (and a collect that named a floor) sandwichable while /// increase still honoured the same fields. function _viaVault(Act memory a) internal { address account = _account; (address vault, address npm) = _venues(a); (address token0, address token1,,,, uint128 liq) = _npmPosition(npm, a.tokenId); uint128 take = 0; if (a.operation == OP_DECREASE) { uint16 bps = a.bps == 0 ? uint16(BPS) : a.bps; take = uint128((uint256(liq) * bps) / BPS); // A DECREASE THAT ROUNDS TO NOTHING IS A COLLECT WEARING ITS NAME. // // The vault reads liquidity zero with the fee caps at max as // "collect the fees and change nothing else" — which is what the // OTHER branch of this function asks for deliberately. So a small // share of a small position (liq 9999 at 1 bps is exactly zero) // succeeded, reported a decrease, and reduced the collateral by // nothing. A later step reading `amountFrom` then treats whatever // dust the fee collection produced as principal. // // Refused rather than rounded up: the plan asked for a fraction of // this position and this position cannot express it, which is a // fact the planner needs rather than a number to invent. if (take == 0) revert ZeroDecrease(a.tokenId, liq, bps); } uint256 b0 = IERC20(token0).balanceOf(account); uint256 b1 = IERC20(token1).balanceOf(account); // THE FEES FIRST, ON THEIR OWN — finding 22 of the 2026-09-26 audit. // // This was one call, liquidity `take` with the fee caps at max, and the // floor rule below read the combined balance change. The vault enforces // `amount0Min`/`amount1Min` on the DECREASE alone, while the delta also // held every fee the collect swept. For a position the price has left, // with fees owed on its empty side, both choices reverted: a floor of 1 // on that side failed the position manager's "Price slippage check", // and a floor of 0 let the swept fees trip `NoUnwindFloor`. OP_DECREASE // could not run at all for that position — M-11 of round 2, which // FlashEngine fixed by reading the decrease's return. The vault's // return is the COLLECT's, fees included, so it cannot say which part // had a price; sweeping the fees first means the second call's delta // is the decrease and nothing else, within one transaction nothing // accrues between them. // // For a collect (`take == 0`) this is the only call, exactly as before. _vaultDecrease(account, vault, a, 0); if (take != 0) { _vaultDecreaseFloored(account, vault, a, take, token0, token1); // THE OWNER'S CEILING AFTER COLLATERAL LEAVES — finding 5 of the // 2026-09-26 audit. A decrease takes liquidity out of the NFT that // backs the vault loan, so it raises the loan's LTV, and the vault // only enforces its OWN collateral factor. `_requireRevertLtv` (H-1) // ran after every borrow and after no withdrawal, so this was the // one path that could strip collateral past the owner's ceiling. // Absolute, not no-more-levered: a decrease IS a collateral // withdrawal and is meant to raise leverage, bounded — the same rule // Morpho's and Aave v4's withdrawals apply. A fee-only collect // (`take == 0`) removes no liquidity, and refusing it on a position a // price move has already pushed over the ceiling would protect // nothing. _requireRevertLtv(vault, a.tokenId); } // MEASURED AS A BALANCE DELTA, never taken from the return value. The // vault reports what it moved and the balance is what actually arrived, // which is the only one a later step can spend. Fees and principal // both: what this step delivered is everything that arrived. _publishDelivered(account, a.taskId, token0, IERC20(token0).balanceOf(account) - b0); _publishDelivered(account, a.taskId, token1, IERC20(token1).balanceOf(account) - b1); } /// One `decreaseLiquidityAndCollect`, to the account, fee caps at max. /// Liquidity zero is the vault's "collect the fees and change nothing else". function _vaultDecrease(address account, address vault, Act memory a, uint128 liquidity) internal { _exec( account, vault, 0, abi.encodeCall( IRevertVault.decreaseLiquidityAndCollect, (IRevertVault.DecreaseAndCollect({ tokenId: a.tokenId, liquidity: liquidity, amount0Min: a.amount0Min, amount1Min: a.amount1Min, feeAmount0: type(uint128).max, feeAmount1: type(uint128).max, deadline: a.deadline, recipient: account })) ) ); } /// The decrease itself, after `_viaVault` swept the fees, held to a real /// floor on every side it produced. /// /// The vault enforces `amount0Min`/`amount1Min` for us, and a ZERO is what /// it cannot enforce — it is the pool's "any ratio at all", so the decrease /// settled at whatever price the transaction it ran in had arranged. Same /// rule and same reasoning as `FlashEngine._decreaseCollect`; this reaches /// the vault's own decrease rather than the position manager's, so it needs /// its own copy. Only the principal is in this delta, because the fees /// left in the call before. function _vaultDecreaseFloored( address account, address vault, Act memory a, uint128 take, address token0, address token1 ) internal { uint256 got0 = IERC20(token0).balanceOf(account); uint256 got1 = IERC20(token1).balanceOf(account); _vaultDecrease(account, vault, a, take); got0 = IERC20(token0).balanceOf(account) - got0; got1 = IERC20(token1).balanceOf(account) - got1; if ((got0 != 0 && a.amount0Min == 0) || (got1 != 0 && a.amount1Min == 0)) { revert NoUnwindFloor(); } } /// TWO VENUES, NOT THREE. It used to resolve `a.aaveVenue` as well and /// EVERY ONE of the six callers discarded it — so `_venueOf`'s refusal was /// the only effect it had, and the effect was that every flash operation on /// this adapter reverted `UnknownVenue` unless the owner had installed a /// venue this contract never touches. Reported in the round-2 audit's LOW /// section; removing it is both the fix and 110 bytes of EIP-170 headroom /// this contract did not have. /// /// `a.aaveVenue` stays in the struct and in the typehash: it is a field the /// signer fills in and the digest covers, and dropping it would move the /// typehash and every signature shape for no gain. function _venues(Act memory a) internal view returns (address vault, address npm) { vault = _venueOf(_account, a.revertVenue); npm = _venueOf(_account, a.npmVenue); } /// Fresh frame so close/split/rebalance do not overflow the stack (via_ir off). function _decreaseCollectAct( Act memory a, address npm, uint128 liq, address token0, address token1 ) internal returns (uint256 got0, uint256 got1) { // 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 ); } /// ONE INCREASE, TWO SHAPES. `increase` funds both legs from the account; /// `increase_lev` funds the FLASH ASSET'S leg from the flash and re-borrows /// it, so it repays `flashAmt` minus that leg rather than the whole flash /// and the loan grows by it. /// /// BOTH LEGS IN BOTH SHAPES — the 2026-10-09 Base campaign /// (a gen-6 fork test, removed with gen 6; TestGen7AdaptersRevertInRangeAdds now runs the add in range). The levered shape forced token0's /// leg to zero and assumed the flash asset was token1. A position the price /// is INSIDE takes both tokens in its own ratio, so a one-sided add mints /// zero liquidity and the pool refuses it: `add_leverage` could only ever /// add to a position wholly on one side of the price. Now each leg is what /// was signed — the flash asset's leg comes out of the flash, the other out /// of the account — and an out-of-range add is the same call with the other /// leg zero, exactly as before. /// /// WHICH LEG IS LEVERED IS READ FROM THE POSITION, never signed: the one /// whose token is `a.asset`. A flash asset that is neither token cannot /// lever this position and is refused, as is a leg larger than the flash /// it is supposed to come out of. /// /// Merged because they were the same twenty lines twice and this contract /// sits near the EIP-170 limit — M-15 of the round-2 audit. function _increaseAny(Act memory a, uint256 flashAmt, bool levered) internal { address account = _account; (address vault, address npm) = _venues(a); (address token0, address token1,,,,) = _npmPosition(npm, a.tokenId); uint256 lev = 0; if (levered) { if (token1 == a.asset) lev = a.amount1; else if (token0 == a.asset) lev = a.amount0; else revert FlashAssetNotInPosition(a.asset, token0, token1); if (lev > flashAmt) revert LeverExceedsFlash(lev, flashAmt); } _revertRepayRemove(account, vault, a.asset, a.tokenId, flashAmt - lev); _charge(account, token0, a.amount0); _charge(account, token1, a.amount1); // Both legs regardless of amount — C-2(d) of the round-2 audit. _approveFrom(account, token0, npm, a.amount0); _approveFrom(account, token1, npm, a.amount1); (uint256 used0, uint256 used1) = _increaseCall(a, npm); _refundIfLess(account, token0, a.amount0, used0); _refundIfLess(account, token1, a.amount1, used1); _requireNoResidual(account, token0, npm); _requireNoResidual(account, token1, npm); _revertCreateBorrow(account, npm, vault, a.asset, a.tokenId, flashAmt); } /// Fresh frame so `_increaseAny` does not overflow the stack (via_ir off). function _increaseCall(Act memory a, address npm) internal returns (uint256 used0, uint256 used1) { bytes memory ret = _exec( _account, npm, 0, abi.encodeCall( INPM.increaseLiquidity, (INPM.IncreaseLiquidityParams({ tokenId: a.tokenId, amount0Desired: a.amount0, amount1Desired: a.amount1, amount0Min: a.amount0Min, amount1Min: a.amount1Min, deadline: a.deadline })) ) ); (, used0, used1) = abi.decode(ret, (uint128, uint256, uint256)); } function _mintFromAct(Act memory a, address npm) internal returns (uint256 tokenId) { MintSpec memory m = MintSpec({ npm: npm, token0: a.token0, token1: a.token1, fee: a.fee, tickLower: a.tickLower, tickUpper: a.tickUpper, amount0: a.amount0, amount1: a.amount1, amount0Min: a.amount0Min, amount1Min: a.amount1Min, deadline: a.deadline }); uint256 used0; uint256 used1; (tokenId, used0, used1) = _mintNpm(m); _refundIfLess(_account, a.token1, a.amount1, used1); _refundIfLess(_account, a.token0, a.amount0, used0); } /// M-12 OF THE ROUND-2 AUDIT: THIS PUBLISHED THE PRE-REPAYMENT DELTA. /// /// The unwind's two balance deltas went onto the bus and THEN `_onFlash` /// pulled `amount + fee` back out of the account to settle the flash — and /// for an ordinary levered Revert position the loan asset IS one of the pool /// tokens. So the bus overstated delivery by the WHOLE FLASH AMOUNT. A /// follow-up step naming `amountFrom = revert_leverage` then spent the /// difference out of whatever idle balance the account happened to hold, or /// reverted the plan on an account that held none. /// /// `LoanCloseAdapter._closeExit` gets this right — it publishes /// `got - assets` — and the two sibling adapters disagreed. The net gain is /// what a later step can actually spend, which is the only number the bus /// has any business carrying. /// /// `_publishDelivered` is silent on zero, so a close whose proceeds exactly /// cover the flash publishes nothing — which is the true statement. function _close(Act memory a, uint256 flashAmt, uint256 owed) internal { address account = _account; (address vault, address npm) = _venues(a); (address token0, address token1,,,, uint128 liq) = _npmPosition(npm, a.tokenId); _revertRepayRemove(account, vault, a.asset, a.tokenId, flashAmt); (uint256 got0, uint256 got1) = _decreaseCollectAct(a, npm, liq, token0, token1); if (token0 == a.asset) { got0 = got0 > owed ? got0 - owed : 0; } else if (token1 == a.asset) { got1 = got1 > owed ? got1 - owed : 0; } _publishDelivered(account, a.taskId, token0, got0); _publishDelivered(account, a.taskId, token1, got1); } function _mintLevered(Act memory a, uint256 flashAmt) internal { address account = _account; (address vault, address npm) = _venues(a); _charge(account, a.token0, a.amount0); _charge(account, a.token1, a.amount1); // Both legs regardless of amount — C-2(d) of the round-2 audit. This // is the mint whose token0/token1 are raw agent-signed addresses, so it // is the one the single-sided-band drain was written against. _approveFrom(account, a.token0, npm, a.amount0); _approveFrom(account, a.token1, npm, a.amount1); uint256 tokenId = _mintFromAct(a, npm); _requireNoResidual(account, a.token0, npm); _requireNoResidual(account, a.token1, npm); uint256 borrowAmt = a.borrowAmount == 0 ? flashAmt : a.borrowAmount; _revertCreateBorrow(account, npm, vault, a.asset, tokenId, borrowAmt); _publishTokenId(account, a.taskId, tokenId); } }