// 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"; import {UniTick} from "./lib/UniTick.sol"; /// The one periphery getter the re-mint gate needs: the factory the position /// manager mints in, so the pool checked is the pool the replacement lands in. interface INPMFactory { function factory() external view returns (address); } /// @title RevertRangeAdapter — the reshaping half of RevertLeverageAdapter. /// /// SPLIT OUT FOR EIP-170, exactly as the round-2 audit's M-15 recommended and /// RevertLeverageAdapter's own header foretold ("If this contract is ever /// split — which the audit recommends"). A shared change to the FlashEngine / /// BaseAdapter base (the per-asset `setCap`) pushed RevertLeverageAdapter 309 /// bytes over the 24,576 limit, and this contract carries the three operations /// that mint a REPLACEMENT NFT — `split`, `rebalance` and `reposition` — off it. /// /// IDENTICAL SIGNING SURFACE. The `Act` struct and `ACT_TYPEHASH` are copied /// verbatim from RevertLeverageAdapter, so a message is encoded and hashed the /// same way on either contract; only the EIP-712 DOMAIN differs, because the /// domain commits to this contract's own address as `verifyingContract`. That /// this contract encodes the Act exactly as RevertLeverageAdapter does is /// checked against the Go encoder by the calldata/digest parity suite, which /// runs the real compiled contract against `internal/adapters`. /// /// These three all take a levered position apart the same way — repay the /// vault, pull the NFT back, decrease-and-collect — and then MINT one or two /// replacements a whitelisted transformer is allowed to create. None of them /// has a no-flash "vault door": adding liquidity and reshaping both need the /// flash loan, which is why they live on the flash path and `collect`/`decrease` /// stayed behind on RevertLeverageAdapter. contract RevertRangeAdapter is FlashEngine { bytes32 internal constant OP_SPLIT = "split"; bytes32 internal constant OP_REBALANCE = "rebalance"; bytes32 internal constant OP_REPOSITION = "reposition"; /// COPIED VERBATIM from RevertLeverageAdapter — the whole point is that the /// two encode a message identically. Do not edit one without the other; the /// parity test fails if they drift. 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; // Only `split` reads it: where the two children meet. See _splitMint. int24 tick; } /// One scratch struct and one unwind for all three rearrangements. Split, /// rebalance and reposition all take the position apart the same way; only /// what they build afterwards differs. struct Unwound { address vault; address npm; address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 amount0; uint256 amount1; } event Acted(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 ("DeFiLoopsRevertRangeAdapter", "1"); } // ---- digest: byte-identical to RevertLeverageAdapter ------------------ 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 ); } // ---- entrypoint ------------------------------------------------------- /// All three operations here take a flash loan; none has a no-flash vault /// door (that path stayed on RevertLeverageAdapter with collect/decrease). 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); 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)); _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; route; address account = _account; _pushToAccount(a.asset, account, amount); uint256 owed = amount + fee; if (a.operation == OP_SPLIT) _split(a, flashAmt); else if (a.operation == OP_REBALANCE) _rebalance(a, flashAmt); // Reposition IS rebalance. Two operation names for one act — the // account's allowlist distinguishes them, which is the point of having // both. else if (a.operation == OP_REPOSITION) _rebalance(a, flashAmt); else revert UnknownOperation(a.operation); _pullFromAccount(a.asset, account, owed); } // ---- shared helpers: copied from RevertLeverageAdapter ----------------- /// TWO VENUES, NOT THREE. `a.aaveVenue` stays in the struct and the typehash /// — it is a signed field the digest covers — but no operation here reads it. 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 the unwinds 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) { return _decreaseCollect( _account, npm, a.tokenId, liq, a.deadline, a.amount0Min, a.amount1Min, token0, token1 ); } // ---- the three reshaping operations ----------------------------------- function _split(Act memory a, uint256 flashAmt) internal { Unwound memory s = _unwind(a, flashAmt); _splitMint(a, s); } /// Repay the vault, take the whole position apart, and stand ready to mint. function _unwind(Act memory a, uint256 flashAmt) internal returns (Unwound memory s) { address account = _account; (s.vault, s.npm) = _venues(a); uint128 liq; (s.token0, s.token1, s.fee, s.tickLower, s.tickUpper, liq) = _npmPosition(s.npm, a.tokenId); _revertRepayRemove(account, s.vault, a.asset, a.tokenId, flashAmt); (s.amount0, s.amount1) = _decreaseCollectAct(a, s.npm, liq, s.token0, s.token1); // NOT AT A PRICE SOMEBODY JUST MOVED — PHASE_C_PRICE_GUARD.md §10.2. // // Every caller of `_unwind` re-mints next (split's two children, // rebalance's and reposition's band), and those mints carry no floor // (see `_splitMint`/`_mintUnwound`), so no signed field binds them. The // uni_* re-mints sit behind this same check: spot within // MAX_UNWIND_TICK_DRIFT (200 ticks, about 2%) of the pool's 300 s TWAP, // or `UniTick.PoolMoved`. It defers while the pool is pushed and refuses // nothing else: an out-of-range band, or one that converts all of one // token into the other, passes on a settled pool. Nothing between here // and the mint trades in this pool, so this is the price it mints at. // // The factory is the position manager's own rather than a // `uniswap_factory` venue, as in SafeExitAdapter: the pool checked is the // one the replacement is minted in, and no account needs re-curating. UniTick.requireSettled(INPMFactory(s.npm).factory(), s.token0, s.token1, s.fee); _approveFrom(account, s.token0, s.npm, s.amount0); _approveFrom(account, s.token1, s.npm, s.amount1); } function _splitMint(Act memory a, Unwound memory s) internal { address account = _account; int24 mid = a.tick; _checkSplitTick(mid, s.tickLower, s.tickUpper, s.fee); // MintParams floors stay 0: the children occupy disjoint ranges, so at // any spot at least one deposits zero of one token and a halved // amountMin reverts `Price slippage check` (UniswapAdapter._split C-4). MintSpec memory m = MintSpec({ npm: s.npm, token0: s.token0, token1: s.token1, fee: s.fee, tickLower: s.tickLower, tickUpper: mid, amount0: s.amount0 / 2, amount1: s.amount1 / 2, amount0Min: 0, amount1Min: 0, deadline: a.deadline }); // AND NO TAKEN-FLOOR EITHER — finding 21 of the 2026-09-26 audit. // // Each child was held to half of `a.amount0Min`/`a.amount1Min`, which // are the BURN's floors. A child whose range straddles the price takes // the two tokens in its own ratio, which is not the burn's, so the side // it takes less of fell short of half the burn floor. The only way to // sign a split that completed was a burn floor low enough for that // child — the audit's case needed under 34% of the burn's token0, and // the suite's fork case under 0.5% of its USDC — and the burn is the // one leg of a split where value can be lost: a price mover took the // rest. UniswapAdapter._split dropped its child floors for the // same reason (C-4) and says why nothing is given up: a child that takes // less than its share leaves the remainder in the ACCOUNT, where it // already was, and `_requireNoResidual` clears the allowance. The // children mint at the price the burn settled at, in the same // transaction, so the burn's floors bound them too. (uint256 id0,,) = _mintNpm(m); m.tickLower = mid; m.tickUpper = s.tickUpper; m.amount0 = s.amount0 - s.amount0 / 2; m.amount1 = s.amount1 - s.amount1 / 2; (uint256 id1,,) = _mintNpm(m); _requireNoResidual(account, s.token0, s.npm); _requireNoResidual(account, s.token1, s.npm); _revertCreateBorrow(account, s.npm, s.vault, a.asset, id0, a.amount0); _revertCreateBorrow(account, s.npm, s.vault, a.asset, id1, a.amount1); _publishTokenId(account, a.taskId, id0); } function _rebalance(Act memory a, uint256 flashAmt) internal { Unwound memory u = _unwind(a, flashAmt); _rebalanceMint(a, u, flashAmt); } function _rebalanceMint(Act memory a, Unwound memory u, uint256 flashAmt) internal { address account = _account; uint256 tokenId = _mintUnwound(a, u); _requireNoResidual(account, u.token0, u.npm); _requireNoResidual(account, u.token1, u.npm); _revertCreateBorrow(account, u.npm, u.vault, a.asset, tokenId, flashAmt); _publishTokenId(account, a.taskId, tokenId); } /// THE REPLACEMENT IS MINTED IN THE POOL THE POSITION CAME FROM. /// /// This minted at `a.fee == 0 ? 500 : a.fee` — a signed field, defaulted /// to a constant — while `_unwind` had just read the position's own tier /// into `u.fee` and nothing used it. A plan that left the fee out moved a /// 0.30% position's liquidity into the 0.05% pool of the same pair (or into /// a pool that does not exist, which reverts from inside the pool with no /// reason), and the Revert loan was re-opened against an NFT in a pool /// nobody chose. A rebalance changes the RANGE; the pool is the position's. /// /// The Go signer fills `fee` from the position and checks it, and that /// stays — but the contract does not lean on it: `u.fee` is what is /// minted, and a signed tier that disagrees is a plan written against a /// different position, refused rather than silently corrected. Zero means /// "not stated" and takes the position's. error FeeTierMismatch(uint24 signed, uint24 position); function _mintUnwound(Act memory a, Unwound memory u) internal returns (uint256 tokenId) { if (a.fee != 0 && a.fee != u.fee) revert FeeTierMismatch(a.fee, u.fee); MintSpec memory m = MintSpec({ npm: u.npm, token0: u.token0, token1: u.token1, fee: u.fee, tickLower: a.tickLower, tickUpper: a.tickUpper, amount0: u.amount0, amount1: u.amount1, // NO FLOOR ON THE NEW BAND, for the reason `_splitMint` gives its // children (finding 21 of the 2026-09-26 audit, and its rebalance // twin found beside it). These were the BURN's floors, and a band // of another shape takes the tokens in another ratio — so the only // rebalance that completed signed a burn floor as low as the new // band's thinner side, and one into a one-sided band never // completed. The burn still carries `a.amount0Min`/`a.amount1Min` // in `_decreaseCollectAct`; what the band does not take stays in // the account. amount0Min: 0, amount1Min: 0, deadline: a.deadline }); (tokenId,,) = _mintNpm(m); } }