// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function approve(address spender, 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; } 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 vault's `tokenOwner` map, read-only. /// /// A second declaration of one function, because `IRevertVault.ownerOf` is not /// marked `view` and a check has no business being a CALL: STATICCALL cannot /// re-enter, and every read `_consume` and the guards do here is one. interface IRevertVaultOwner { function ownerOf(uint256 tokenId) external view returns (address); /// `(debt, fullValue, collateralValue, liquidationCost, liquidationValue)`. /// The first two are the owner's LTV ceiling — H-1 of the round-2 audit. /// See `_requireRevertLtv`. function loanInfo(uint256 tokenId) external view returns (uint256 debt, uint256 fullValue, 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 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 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 RevertFiAdapter — Revert V3 vault ops. NFT/loan always on the account. contract RevertFiAdapter is BaseAdapter { uint256 internal constant BPS = 10_000; bytes32 public constant OP_CREATE_BORROW = "create_borrow"; bytes32 public constant OP_MINT_CREATE_BORROW = "mint_create_borrow"; bytes32 public constant OP_BORROW = "borrow"; bytes32 public constant OP_REPAY = "repay"; bytes32 public constant OP_REMOVE = "remove"; bytes32 public constant OP_REPAY_WITHDRAW = "repay_withdraw"; bytes32 internal constant ACT_TYPEHASH = keccak256( "Act(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "bytes32 venue,bytes32 npmVenue,address asset,address token0,address token1,uint24 fee," "int24 tickLower,int24 tickUpper,uint256 tokenId,uint256 amount0,uint256 amount1," "uint256 amount0Min,uint256 amount1Min,uint256 amount,address amountFrom,bytes32 amountFromTask)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; bytes32 venue; bytes32 npmVenue; address asset; address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 tokenId; uint256 amount0; uint256 amount1; uint256 amount0Min; uint256 amount1Min; uint256 amount; address amountFrom; bytes32 amountFromTask; } event Acted(address indexed account, bytes32 indexed operation, uint256 tokenId); error ApproveFailed(); error UnknownOperation(bytes32 operation); error NotNftOwner(address account, uint256 tokenId); error NothingArrived(address asset); /// An unwind with no floor on a side that actually produced tokens. H-9. error NoUnwindFloor(); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsRevertFiAdapter", "1"); } function digest(Act calldata a) public view returns (bytes32) { return _hashTypedData(keccak256(bytes.concat(_head(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.venue, a.npmVenue, a.asset ); } function _tail(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.token0, a.token1, a.fee, a.tickLower, a.tickUpper, a.tokenId, a.amount0, a.amount1, a.amount0Min, a.amount1Min, a.amount, a.amountFrom, a.amountFromTask ); } function act(Act calldata a, bytes calldata signature) external returns (uint256 tokenId) { _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); address vault = _venueOf(a.account, a.venue); address npm = _venueOf(a.account, a.npmVenue); if (a.operation == OP_CREATE_BORROW) { tokenId = _createBorrow(a, vault, npm); } else if (a.operation == OP_MINT_CREATE_BORROW) { tokenId = _mintCreateBorrow(a, vault, npm); } else if (a.operation == OP_BORROW) { _borrow(a, vault); tokenId = a.tokenId; } else if (a.operation == OP_REPAY) { _repay(a, vault); tokenId = a.tokenId; } else if (a.operation == OP_REMOVE) { _remove(a, vault, npm); tokenId = a.tokenId; } else if (a.operation == OP_REPAY_WITHDRAW) { _repayWithdraw(a, vault, npm); tokenId = a.tokenId; } else { revert UnknownOperation(a.operation); } emit Acted(a.account, a.operation, tokenId); } function _approve(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(); } /// 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 _requireNpmOwner(address account, address npm, uint256 tokenId) internal view { if (IERC721(npm).ownerOf(tokenId) != account) revert NotNftOwner(account, tokenId); } /// WHOSE LOAN IS THIS, according to the vault. /// /// Not `IERC721(npm).ownerOf` — the vault HOLDS the NFT while it is /// collateral, so the position manager names the vault. The vault keeps its /// own `tokenOwner` map and exposes it as `ownerOf`; a tokenId with no loan /// answers `address(0)`. /// /// This is the check `_repay` did not make, and its absence was a High. /// Revert's `V3Vault.repay` has NO access control — repay-on-behalf is /// normal for a lending protocol — while `remove`, `borrow` and /// `decreaseLiquidityAndCollect` all enforce `tokenOwner[tokenId] == /// msg.sender`. So a standalone `repay` naming somebody else's tokenId /// moved the victim's USDC to the vault and extinguished the ATTACKER's /// debt, who then called `remove` and walked away: cap-limited theft /// outright rather than cap-limited risk. /// /// `OP_REPAY_WITHDRAW` and `RevertLeverageAdapter._revertRepayRemove` were /// never exploitable, because each follows the repay with an /// ownership-gated `remove` that reverts the whole transaction. Only the /// standalone `OP_REPAY` was open, and `test_NotNftOwner` covered only /// `OP_CREATE_BORROW`. function _requireLoanOwner(address account, address vault, uint256 tokenId) internal view { if (IRevertVaultOwner(vault).ownerOf(tokenId) != account) { revert NotNftOwner(account, tokenId); } } function _createBorrow(Act calldata a, address vault, address npm) internal returns (uint256 tokenId) { tokenId = a.tokenId; _requireNpmOwner(a.account, npm, tokenId); _exec(a.account, npm, 0, abi.encodeCall(IERC721.approve, (vault, tokenId))); _exec(a.account, vault, 0, abi.encodeCall(IRevertVault.create, (tokenId, a.account))); uint256 amount = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, a.asset); if (amount == 0) return tokenId; uint256 before = IERC20(a.asset).balanceOf(a.account); _exec(a.account, vault, 0, abi.encodeCall(IRevertVault.borrow, (tokenId, amount))); uint256 got = IERC20(a.asset).balanceOf(a.account); if (got <= before) revert NothingArrived(a.asset); _requireRevertLtv(vault, a.account, tokenId); _publishDelivered(a.account, a.taskId, a.asset, got - before); } function _mintCreateBorrow(Act calldata a, address vault, address npm) internal returns (uint256 tokenId) { // BOTH TOKENS CURATED, REGARDLESS OF AMOUNT — C-2(d) of the round-2 // audit, and finding 12 of the 2026-09-26 audit, which found it open. // // A zero `amount1` and a single-sided band above spot put 100% of the // token0 amount into a pool the agent chose, at a price it set, and it // swaps the account's side out. `_spendCap(token1, 0)` used to refuse // that by requiring a cap row only curated tokens had; the cap was // removed on 2026-09-20 (ad353913) and nothing replaced the check here. // The rebuilt cap below is opt-in and waves an uncapped asset through, // so curation is stated explicitly. Whether Revert's vault would refuse // to `create` against such a position was never confirmed, and with // `amount == 0` no borrow ever asks it; this does not depend on it. _requireCuratedAsset(a.token0); _requireCuratedAsset(a.token1); uint256 amount0 = _amountToSpend(a.account, a.amount0, a.amountFrom, a.amountFromTask, a.token0); uint256 amount1 = a.amount1; _charge(a.account, a.token0, amount0); _charge(a.account, a.token1, amount1); _approve(a.account, a.token0, npm, amount0); _approve(a.account, a.token1, npm, amount1); bytes memory ret = _exec( a.account, npm, 0, abi.encodeCall( INPM.mint, (INPM.MintParams({ token0: a.token0, token1: a.token1, fee: a.fee, tickLower: a.tickLower, tickUpper: a.tickUpper, amount0Desired: amount0, amount1Desired: amount1, amount0Min: a.amount0Min, amount1Min: a.amount1Min, recipient: a.account, deadline: a.deadline })) ) ); uint256 used0; uint256 used1; (tokenId,, used0, used1) = abi.decode(ret, (uint256, uint128, uint256, uint256)); _refundIfLess(a.account, a.token1, amount1, used1); _refundIfLess(a.account, a.token0, amount0, used0); _requireNoResidual(a.account, a.token0, npm); if (amount1 > 0) _requireNoResidual(a.account, a.token1, npm); _exec(a.account, npm, 0, abi.encodeCall(IERC721.approve, (vault, tokenId))); _exec(a.account, vault, 0, abi.encodeCall(IRevertVault.create, (tokenId, a.account))); uint256 borrowAmt = a.amount; if (borrowAmt == 0) { _publishTokenId(a.account, a.taskId, tokenId); return tokenId; } uint256 before = IERC20(a.asset).balanceOf(a.account); _exec(a.account, vault, 0, abi.encodeCall(IRevertVault.borrow, (tokenId, borrowAmt))); uint256 got = IERC20(a.asset).balanceOf(a.account); if (got <= before) revert NothingArrived(a.asset); _requireRevertLtv(vault, a.account, tokenId); _publishDelivered(a.account, a.taskId, a.asset, got - before); } function _borrow(Act calldata a, address vault) internal { uint256 amount = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, a.asset); uint256 before = IERC20(a.asset).balanceOf(a.account); _exec(a.account, vault, 0, abi.encodeCall(IRevertVault.borrow, (a.tokenId, amount))); uint256 got = IERC20(a.asset).balanceOf(a.account); if (got <= before) revert NothingArrived(a.asset); _requireRevertLtv(vault, a.account, a.tokenId); _publishDelivered(a.account, a.taskId, a.asset, got - before); } /// THE OWNER'S CEILING ON A REVERT FINANCE LOAN — H-1 of the round-2 audit. /// /// This adapter has no LTV field in its `Act` at all, which is why the bound /// is the owner's ceiling and not a signed one: it needs nothing signed. The /// vault's own collateral factor is not a substitute — that is the venue's /// bound, and the whole point of `AgentRegistry.ltvCeilingOf` is that the owner /// may want to sit well inside it. /// /// `fullValue` is the position's worth at the vault's oracle; /// `collateralValue` is the same number already multiplied by the vault's /// factor, so `debt / fullValue` is the ratio the ceiling is stated in. /// A staticcall for the reason `IRevertVaultOwner` exists at all: a read has /// no business being a CALL. function _requireRevertLtv(address vault, address account, uint256 tokenId) internal view { (uint256 debt, uint256 fullValue,,,) = IRevertVaultOwner(vault).loanInfo(tokenId); if (debt == 0) return; _requireLtvCeiling( account, fullValue == 0 ? type(uint256).max : (debt * BPS + fullValue - 1) / fullValue ); } function _repay(Act calldata a, address vault) internal { _requireLoanOwner(a.account, vault, a.tokenId); uint256 amount = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, a.asset); _charge(a.account, a.asset, amount); uint256 before = IERC20(a.asset).balanceOf(a.account); _approve(a.account, a.asset, vault, amount); bytes memory ret = _exec( a.account, vault, 0, abi.encodeCall(IRevertVault.repay, (a.tokenId, amount, false)) ); uint256 paid = amount; if (ret.length >= 64) { (uint256 assets,) = abi.decode(ret, (uint256, uint256)); paid = assets; } else { paid = before - IERC20(a.asset).balanceOf(a.account); } _requireNoResidual(a.account, a.asset, vault); if (paid < amount) _refund(a.account, a.asset, amount - paid, amount); } function _remove(Act calldata a, address vault, address npm) internal { _exec( a.account, vault, 0, abi.encodeCall(IRevertVault.remove, (a.tokenId, a.account, bytes(""))) ); if (IERC721(npm).ownerOf(a.tokenId) != a.account) revert NotNftOwner(a.account, a.tokenId); _publishTokenId(a.account, a.taskId, a.tokenId); } function _repayWithdraw(Act calldata a, address vault, address npm) internal { if (a.amount > 0) _repay(a, vault); _remove(a, vault, npm); (,, address token0, address token1,,,, uint128 liquidity,,,,) = INPM(npm).positions(a.tokenId); uint256 before0 = IERC20(token0).balanceOf(a.account); uint256 before1 = IERC20(token1).balanceOf(a.account); bytes memory dec = _exec( a.account, npm, 0, abi.encodeCall( INPM.decreaseLiquidity, (INPM.DecreaseLiquidityParams({ tokenId: a.tokenId, liquidity: liquidity, amount0Min: a.amount0Min, amount1Min: a.amount1Min, deadline: a.deadline })) ) ); _exec( a.account, npm, 0, abi.encodeCall( INPM.collect, (INPM.CollectParams({ tokenId: a.tokenId, recipient: a.account, amount0Max: type(uint128).max, amount1Max: type(uint128).max })) ) ); uint256 got0 = IERC20(token0).balanceOf(a.account) - before0; uint256 got1 = IERC20(token1).balanceOf(a.account) - before1; // H-9 OF THE ROUND-2 AUDIT: EVERY UNWIND CARRIES A FLOOR, AND THIS ONE // DID NOT. // // Commit 27a93d5 added `NoUnwindFloor` to UniswapAdapter, FlashEngine // and RevertLeverageAdapter and said "every unwind carries a floor". // THIS FILE WAS NOT IN THE LIST — it inherits neither helper and emits // its own `decreaseLiquidity`/`collect` pair inline — so // `a.amount0Min`/`a.amount1Min` went straight through with no non-zero // check, against the FULL `liquidity` read from `positions()`. The // repo's own `test_RepayWithdraw` built its Act with `_revAct`, which // never sets either floor, and passed. // // What that bought: flash-borrow, push the pool to an extreme, run this // step at `amount0Min = amount1Min = 0`, push back. The unwind is an // INFLOW, so `_spendCap` is never called and `spent` is unchanged — // repeatable next block with a fresh `taskId`, and the loss ceiling is // the pool's depth rather than the owner's epoch budget. // // The same rule the other four sites use, measured the same way: a side // that produced tokens had a price, and a price with no floor is // whatever the transaction it ran in had arranged. A side that produced // nothing has nothing to bound. // // `liquidity == 0` is a fee collect wearing a withdraw's shape — fees // settle at no price, so there is no floor to demand. // // HELD TO THE DECREASE'S OWN RETURN, NOT TO `got0`/`got1` — finding 20 // of the 2026-09-26 audit, M-11 of round 2 again. The deltas span the // `collect` too, so they include fees, while the position manager // enforces the floors on the decrease alone. For a position the price // has left, with fees owed on its empty side, both choices reverted: // a floor of 1 on that side fails the NPM's "Price slippage check", a // floor of 0 let the swept fees trip this check. The Revert position // could not be repaid and withdrawn at all. The decrease's two return // values are the sides that had a price; fees had none. if (liquidity != 0) { (uint256 dec0, uint256 dec1) = abi.decode(dec, (uint256, uint256)); if ((dec0 != 0 && a.amount0Min == 0) || (dec1 != 0 && a.amount1Min == 0)) { revert NoUnwindFloor(); } } _publishDelivered(a.account, a.taskId, token0, got0); _publishDelivered(a.account, a.taskId, token1, got1); } }