// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {IAggregationRouter} from "./SwapAdapter.sol"; import {UniTick} from "./lib/UniTick.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); } interface IUniFactory { function getPool(address tokenA, address tokenB, uint24 fee) external view returns (address); } interface IUniPool { function slot0() external view returns ( uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked ); function tickSpacing() external view returns (int24); } /// The two words of `slot0` `_requireFunded` reads. Decoding all seven costs /// bytes this contract does not have; the ABI decoder takes a prefix. interface IUniSpot { function slot0() external view returns (uint160 sqrtPriceX96, int24 tick); } 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 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1 ); } /// @title UniswapAdapter — Uni V3 LP ops, NFT always minted to the account. contract UniswapAdapter is BaseAdapter { bytes32 public constant OP_MINT = "mint"; bytes32 public constant OP_MINT_SINGLE = "mint_single"; bytes32 public constant OP_INCREASE = "increase"; bytes32 public constant OP_DECREASE = "decrease"; bytes32 public constant OP_COLLECT = "collect"; bytes32 public constant OP_WITHDRAW = "withdraw"; bytes32 public constant OP_REBALANCE = "rebalance"; bytes32 public constant OP_SPLIT = "split"; bytes32 public constant OP_REPOSITION = "reposition"; bytes32 public constant OP_CLOSE_AT_TICK = "close_at_tick"; uint256 internal constant BPS = 10_000; bytes32 internal constant ACT_TYPEHASH = keccak256( "Act(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "bytes32 venue,bytes32 routerVenue,address token0,address token1,uint24 fee," "int24 tickLower,int24 tickUpper,int24 tick,int24 tickSlippage,uint256 tokenId," "uint256 amount0,uint256 amount1,uint256 amount0Min,uint256 amount1Min,uint256 amount," "address amountFrom,bytes32 amountFromTask,uint256 minOut,uint16 bps)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; bytes32 venue; bytes32 routerVenue; address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; int24 tick; int24 tickSlippage; uint256 tokenId; uint256 amount0; uint256 amount1; uint256 amount0Min; uint256 amount1Min; uint256 amount; address amountFrom; bytes32 amountFromTask; uint256 minOut; uint16 bps; } event Acted(address indexed account, bytes32 indexed operation, uint256 tokenId); error ApproveFailed(); // No-arg errors: each typed arg is an ABI encode in the bytecode, and this // contract sits on EIP-170. Callers recover context from the signed Act. error UnknownOperation(); error NotNftOwner(); error TickOutOfRange(); error BothAmountsSet(); error NoFloor(); /// An unwind with no floor on a side that actually produced tokens. error NoUnwindFloor(); /// A bps decrease that rounds to zero liquidity — a collect wearing /// decrease's name. H-7. error ZeroDecrease(); /// The position manager answered nothing usable for this tokenId. error NoPosition(); error NoRoute(); error SameAsset(); error DeliveredLessThanFloor(); error NothingArrived(); /// A rebalance's new band needs a token the burn did not return and the /// plan did not top up, so the position manager would mint zero liquidity /// and the pool would refuse it with an EMPTY revert. See `_requireFunded`. error BandNotFunded(); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsUniswapAdapter", "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.venue, a.routerVenue, a.token0 ); } function _mid(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.token1, a.fee, a.tickLower, a.tickUpper, a.tick, a.tickSlippage, a.tokenId, a.amount0 ); } function _tail(Act calldata a) internal pure returns (bytes memory) { return abi.encode( a.amount1, a.amount0Min, a.amount1Min, a.amount, a.amountFrom, a.amountFromTask, a.minOut, a.bps ); } function act(Act calldata a, bytes calldata signature, IAggregationRouter.Path[] calldata route) external returns (uint256 tokenId) { _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); address npm = _venueOf(a.account, a.venue); if (a.operation == OP_MINT) { tokenId = _mint(a, npm); } else if (a.operation == OP_MINT_SINGLE) { tokenId = _mintSingle(a, npm); } else if (a.operation == OP_INCREASE) { _increase(a, npm); tokenId = a.tokenId; } else if (a.operation == OP_DECREASE) { _decrease(a, npm); tokenId = a.tokenId; } else if (a.operation == OP_COLLECT) { _collect(a, npm); tokenId = a.tokenId; } else if (a.operation == OP_WITHDRAW) { _withdraw(a, npm, route); tokenId = a.tokenId; } else if (a.operation == OP_REBALANCE) { tokenId = _rebalance(a, npm); } else if (a.operation == OP_SPLIT) { tokenId = _split(a, npm); } else if (a.operation == OP_REPOSITION) { tokenId = _reposition(a, npm); } else if (a.operation == OP_CLOSE_AT_TICK) { _closeAtTick(a, npm); tokenId = a.tokenId; } else { revert UnknownOperation(); } emit Acted(a.account, a.operation, tokenId); } function _requireOwner(address account, address npm, uint256 tokenId) internal view { if (IERC721(npm).ownerOf(tokenId) != account) revert NotNftOwner(); } /// The six fields of `positions()` this file 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 a contract that /// had two hundred to spare. All twelve are STATIC, so each is 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 what the decoder's range checks would otherwise /// enforce: a venue answering with dirt in the high bits of `fee` gets it /// dropped rather than reverting. The npm is an owner-installed venue /// (`_venueOf`), never something an intent can name. function _position(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(); uint256 w; assembly { w := mload(add(ret, 0x60)) } token0 = address(uint160(w)); assembly { w := mload(add(ret, 0x80)) } token1 = address(uint160(w)); assembly { w := mload(add(ret, 0xA0)) } fee = uint24(w); assembly { w := mload(add(ret, 0xC0)) } tickLower = int24(int256(w)); assembly { w := mload(add(ret, 0xE0)) } tickUpper = int24(int256(w)); assembly { w := mload(add(ret, 0x100)) } liquidity = uint128(w); } 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); } /// A REARRANGEMENT IS NOT A SPEND. /// /// split / rebalance / reposition burn a position and re-mint it, so most /// of what the new mint consumes is the burn's OWN proceeds -- money that /// never left the account. Only the part the owner tops up beyond what the /// burn returned is an outflow, and that is what is charged. function _spendTopUp(address account, address asset, uint256 want, uint256 fromBurn) internal { if (want <= fromBurn) return; _charge(account, asset, want - fromBurn); } /// Give back the part of a top-up the pool did not take. /// /// Bounded by what `_spendTopUp` actually charged, which is the whole /// point: a mint takes a RATIO of the two desired amounts, so most of an /// unused remainder is the burn's own proceeds -- never charged, and /// refunding it would hand the epoch budget nobody spent. function _refundTopUp( address account, address asset, uint256 want, uint256 fromBurn, uint256 used ) internal { if (want <= fromBurn || used >= want) return; uint256 unused = want - used; uint256 charged = want - fromBurn; _refund(account, asset, unused < charged ? unused : charged, charged); } /// How far back the anchor tick is averaged over. /// /// `slot0().tick` is SPOT. Every tick this file used to read came from it, /// and a tick nobody can bound is a band nobody can bound: `_reposition` /// re-mints into a ONE-SPACING band anchored on whatever the tick says, and /// `close_at_tick` gates a full unwind on it against an agent-chosen /// `tickSlippage`. Both were therefore decided inside the attacker's own /// transaction — flash-borrow, swap the pool to an extreme, act, swap back, /// and the return leg is cheaper because the victim's liquidity was /// removed from the pool between the legs. No mempool needed, so the "Base /// and Arbitrum have no public pending mempool" argument does not reach it. /// /// Five minutes: long enough that holding the price across it costs real /// money and cannot be done inside one transaction, short enough that a /// genuine move is followed rather than fought. The window and the reads /// live in `UniTick`. function _splitMid(Act calldata a, uint24 fee, int24 tickLower, int24 tickUpper) internal pure returns (int24 mid) { mid = a.tick; _checkSplitTick(mid, tickLower, tickUpper, fee); } /// A mint whose two desired amounts were CHARGED to the epoch in full. /// /// `_mint` and `_mintSingle` spend the cap on the ceilings before they call, /// so an unused remainder here really is budget that never left and is /// refunded. That is the opposite of `_mintP`'s callers — see the note /// there — which is why the refund lives in one of the two and not in the /// shared body. function _mintCall( Act calldata a, address npm, int24 tickLower, int24 tickUpper, uint256 amount0, uint256 amount1 ) internal returns (uint256 tokenId) { INPM.MintParams memory p; p.token0 = a.token0; p.token1 = a.token1; p.fee = a.fee; p.tickLower = tickLower; p.tickUpper = tickUpper; p.amount0Desired = amount0; p.amount1Desired = amount1; p.amount0Min = a.amount0Min; p.amount1Min = a.amount1Min; p.recipient = a.account; p.deadline = a.deadline; uint256 used0; uint256 used1; (tokenId, used0, used1) = _mintP(a.account, npm, p); _refundIfLess(a.account, a.token1, amount1, used1); _refundIfLess(a.account, a.token0, amount0, used0); } /// Take `liquidity` out of the position and sweep everything owed to the /// account, honouring the signed floors on the way out. /// /// ONE COPY, because `_burnAll` and `_decrease` are the same two calls with /// a different share and this contract is within a few hundred bytes of /// EIP-170's ceiling — two copies of the encode is bytecode that buys /// nothing. The collect is unconditional and uncapped: what is owed is owed /// whether or not any liquidity moved, which is what makes `collect` a /// separate operation as well as the tail of this one. /// EVERY UNWIND CARRIES A FLOOR ON EVERY SIDE IT PRODUCES. /// /// `decreaseLiquidity` settles at the pool's CURRENT price, and the only /// bound on that price is `amount0Min` / `amount1Min` — two fields of the /// intent B signs, whose default is zero, which is the pool's "any ratio at /// all". Nothing checked them. So a compromised B could flash-borrow, swap /// the pool to an extreme, unwind the victim's position at that price, swap /// back cheaper (the victim's liquidity is no longer there to sell into), /// and repeat next block with a fresh taskId — all inside one transaction, /// so no mempool is involved. `FlashEngine._swap` already refuses a zero /// floor for exactly this reason; the six operations that reach a burn did /// not. /// /// CHECKED AFTER, AGAINST WHAT ACTUALLY ARRIVED, rather than as a blanket /// `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. Asking what the burn produced answers it exactly: a side /// that delivered tokens under no floor is the finding, a side that /// delivered nothing is not. /// /// A non-zero floor is then enforced by the position manager itself, which /// reverts `Price slippage check`. This only refuses the ZERO. /// Returns fee-inclusive deltas AND the decrease's own principal (dec*). /// Callers that publish to the bus must use dec*, not got*, so fees are not /// spent as unwind principal by a later step. function _decreaseCollect( Act calldata a, address npm, uint128 liquidity, address token0, address token1 ) internal returns (uint256 got0, uint256 got1, uint256 dec0, uint256 dec1) { if (liquidity != 0) { (,, uint24 fee,,,) = _position(npm, a.tokenId); UniTick.requireSettled( _venueOf(a.account, bytes32("uniswap_factory")), token0, token1, fee ); } got0 = IERC20(token0).balanceOf(a.account); got1 = 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 })) ) ); _collectAll(a, npm); // MEASURED HERE AND HANDED BACK, rather than measured again by every // caller. Every caller needs the two deltas immediately afterwards, so // they were being read twice — four extra staticcalls a call. got0 = IERC20(token0).balanceOf(a.account) - got0; got1 = IERC20(token1).balanceOf(a.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, 0, 0); // THE FLOOR IS HELD TO THE DECREASE'S OWN RETURN, NOT TO THE DELTAS — // finding 23 of the 2026-09-26 audit, and M-11 of round 2 over again. // // `got0`/`got1` span `decreaseLiquidity` AND `collect`, so they include // FEES, while the position manager enforces the floors on the decrease // alone. A position wholly on one side of the price, with fees owed on // the empty side, reverted on both branches: `amount1Min = 1` fails the // NPM's "Price slippage check" (the decrease really does produce zero // token1), and `amount1Min = 0` let `collect` sweep the fees and this // check fire. Decrease, rebalance, split and reposition all route here, // so none of them worked for that position — which is what a range the // price has left looks like. FlashEngine made this change for M-11 and // this copy of the check was not in the list. // // The decrease's two return values are the sides that had a PRICE; // fees had none, the same reason `liquidity == 0` returns above. (dec0, dec1) = abi.decode(dec, (uint256, uint256)); // ONE REVERT SITE, and a no-argument error: 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 && a.amount0Min == 0) || (dec1 != 0 && a.amount1Min == 0)) { revert NoUnwindFloor(); } } function _collectAll(Act calldata a, address npm) internal { _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 })) ) ); } function _burnAll(Act calldata a, address npm) internal returns (uint256 got0, uint256 got1) { (address token0, address token1,,,, uint128 liquidity) = _position(npm, a.tokenId); (got0, got1,,) = _decreaseCollect(a, npm, liquidity, token0, token1); } /// Mint, and REPORT what the pool took rather than refunding it. /// /// Its three callers — split, rebalance, reposition — are rearrangements /// (see `_spendTopUp`) and charge the epoch nothing for the burn's /// own proceeds. A blanket `_refundIfLess` here would then CREDIT budget /// that was never spent: a rebalance would come back from the epoch with /// more allowance than it started with, which is a way past the ceiling /// rather than a rounding nicety. Only `_rebalance` can charge anything at /// all — the top-up above the burn — and only it refunds, against exactly /// that figure. /// /// `_mintCall` is the other shape and keeps its refund: `_mint` and /// `_mintSingle` charge the full desired amounts up front, so an unused /// remainder there really is budget that never left. function _mintP(address account, address npm, INPM.MintParams memory p) internal returns (uint256 tokenId, uint256 used0, uint256 used1) { bytes memory ret = _exec(account, npm, 0, abi.encodeCall(INPM.mint, (p))); (tokenId,, used0, used1) = abi.decode(ret, (uint256, uint128, uint256, uint256)); } function _mint(Act calldata a, address npm) internal returns (uint256 tokenId) { // BOTH TOKENS CURATED, REGARDLESS OF AMOUNT — C-2(d) of the round-2 // audit, and finding 1 of the 2026-09-26 audit, which found it open. // // The attack: CREATE2 a JUNK address above USDC so `token0 < token1` // holds, initialise the pool at a price of your choosing, and mint with // `amount1 = 0` into a band entirely above spot. `LiquidityAmounts` then // takes 100% of `amount0`, so the account's USDC lands in the agent's // own pool; `recipient` is the account throughout, so nothing looks // wrong until the agent swaps JUNK back through the band and takes it. // // This used to be closed by the spend cap: charging `_spendCap(token1, // 0)` required a cap row, and only a curated token had one. ad353913 // (2026-09-20) removed the cap and replaced that implicit check with // explicit `_requireCuratedAsset` calls — in `_mintSingle` and not // here. Generation 4 shipped without it. The charge below is the // rebuilt, OPT-IN cap and an uncapped asset returns straight through it, // so curation is stated explicitly and first, before anything moves. _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); // NOT AT A PRICE THIS TRANSACTION MOVED — finding 13 of the 2026-09-26 // audit. Every other path that touches liquidity already asks: the // decrease runs this check (H-6), and the band re-mints anchor with // `UniTick.bandAnchor`. A plain mint took whatever slot0 said, bounded // only by `amount0Min`/`amount1Min`, which are the keeper's own // numbers — so a keeper could push the pool, add the account's // liquidity at the pushed price, and let it revert. UniTick.requireSettled( _venueOf(a.account, bytes32("uniswap_factory")), a.token0, a.token1, a.fee ); _approve(a.account, a.token0, npm, amount0); _approve(a.account, a.token1, npm, amount1); tokenId = _mintFresh(a, npm, a.tickLower, a.tickUpper, amount0, amount1); } /// Mint a NEW position and close the operation out. /// /// Shared by mint and mint_single, whose tails were identical. The residual /// checks are conditional on the amount because `_approve` returns early on /// zero — there is no allowance to have left behind for a side that was /// never funded, and checking one costs a call per mint in a contract that /// has to fit inside EIP-170. function _mintFresh( Act calldata a, address npm, int24 lower, int24 upper, uint256 amount0, uint256 amount1 ) internal returns (uint256 tokenId) { tokenId = _mintCall(a, npm, lower, upper, amount0, amount1); if (amount0 > 0) _requireNoResidual(a.account, a.token0, npm); if (amount1 > 0) _requireNoResidual(a.account, a.token1, npm); _publishTokenId(a.account, a.taskId, tokenId); } function _mintSingle(Act calldata a, address npm) internal returns (uint256 tokenId) { if (a.amount0 != 0 && a.amount1 != 0) revert BothAmountsSet(); // C-2(d). One side is zero BY CONSTRUCTION here, so the conditional // `_spendCap` below can never reach it — this is the same single-sided // band the finding describes, made an explicit operation. Both rows are // required up front; the curated asset set is the list of what this // chain deals in, and a pool leg is dealing in it. _requireCuratedAsset(a.token0); _requireCuratedAsset(a.token1); address factory = _venueOf(a.account, bytes32("uniswap_factory")); (int24 tick, int24 spacing) = UniTick.bandAnchor(factory, a.token0, a.token1, a.fee, a.amount0 > 0); (int24 lo, int24 hi) = UniTick.outOfRangeBand(tick, spacing, a.amount0 > 0); uint256 spend0 = a.amount0; uint256 spend1 = a.amount1; if (spend0 > 0) { spend0 = _amountToSpend(a.account, spend0, a.amountFrom, a.amountFromTask, a.token0); _charge(a.account, a.token0, spend0); _approve(a.account, a.token0, npm, spend0); } if (spend1 > 0) { spend1 = _amountToSpend(a.account, spend1, a.amountFrom, a.amountFromTask, a.token1); _charge(a.account, a.token1, spend1); _approve(a.account, a.token1, npm, spend1); } tokenId = _mintFresh(a, npm, lo, hi, spend0, spend1); } function _increase(Act calldata a, address npm) internal { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1, uint24 fee,,,) = _position(npm, a.tokenId); uint256 amount0 = _amountToSpend(a.account, a.amount0, a.amountFrom, a.amountFromTask, token0); uint256 amount1 = a.amount1; _charge(a.account, token0, amount0); _charge(a.account, token1, amount1); // The same gate as `_mint`, for the same reason: adding liquidity is the // other half of the sandwich a decrease is already protected from. UniTick.requireSettled( _venueOf(a.account, bytes32("uniswap_factory")), token0, token1, fee ); _approve(a.account, token0, npm, amount0); _approve(a.account, token1, npm, amount1); bytes memory ret = _exec( a.account, npm, 0, abi.encodeCall( INPM.increaseLiquidity, (INPM.IncreaseLiquidityParams({ tokenId: a.tokenId, amount0Desired: amount0, amount1Desired: amount1, amount0Min: a.amount0Min, amount1Min: a.amount1Min, deadline: a.deadline })) ) ); uint256 used0; uint256 used1; (, used0, used1) = abi.decode(ret, (uint128, uint256, uint256)); _refundIfLess(a.account, token1, amount1, used1); _refundIfLess(a.account, token0, amount0, used0); _requireNoResidual(a.account, token0, npm); if (amount1 > 0) _requireNoResidual(a.account, token1, npm); } function _decrease(Act calldata a, address npm) internal { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1,,,, uint128 liquidity) = _position(npm, a.tokenId); uint16 bps = a.bps == 0 ? uint16(BPS) : a.bps; uint128 take = uint128((uint256(liquidity) * bps) / BPS); if (take == 0) revert ZeroDecrease(); // Publish decrease principal (dec*), not fee-inclusive got* — otherwise // a later step can spend collected fees as if they were unwind principal. (,, uint256 dec0, uint256 dec1) = _decreaseCollect(a, npm, take, token0, token1); _publishDelivered(a.account, a.taskId, token0, dec0); _publishDelivered(a.account, a.taskId, token1, dec1); } function _collect(Act calldata a, address npm) internal { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1,,,,) = _position(npm, a.tokenId); uint256 before0 = IERC20(token0).balanceOf(a.account); uint256 before1 = IERC20(token1).balanceOf(a.account); _collectAll(a, npm); uint256 got0 = IERC20(token0).balanceOf(a.account) - before0; uint256 got1 = IERC20(token1).balanceOf(a.account) - before1; _publishDelivered(a.account, a.taskId, token0, got0); _publishDelivered(a.account, a.taskId, token1, got1); } function _withdraw(Act calldata a, address npm, IAggregationRouter.Path[] calldata route) internal { (address pos0, address pos1,,,,) = _position(npm, a.tokenId); uint256 beforeIn = IERC20(a.token0).balanceOf(a.account); _decrease(a, npm); if (a.minOut == 0) return; if (route.length == 0) revert NoRoute(); if (a.token0 == a.token1) revert SameAsset(); if (a.token0 != pos0 && a.token0 != pos1) revert SameAsset(); // C-2 OF THE ROUND-2 AUDIT: `a.token1` IS THE SWAP'S DESTINATION AND WAS // PINNED TO NOTHING. The line above pins `a.token0` to a token the // position actually holds; `a.token1` is the router's `tokenOut` and is // a free agent-signed address. `a.minOut` is agent-signed too and // `minOut = 1` is legal — zero merely skips the swap. So: burn the whole // position, sell 100,000 USDC into a pool the agent seeded against a // token it deployed, deliver one wei, pass the floor. The full epoch // cap, per epoch, rather than a slippage fraction of it, and the `Acted` // event names only `(account, operation, tokenId)` so the destination // token never appears in a log. See BaseAdapter._requireCuratedAsset. _requireCuratedAsset(a.token1); address router = _venueOf(a.account, a.routerVenue); uint256 amountIn = IERC20(a.token0).balanceOf(a.account) - beforeIn; if (amountIn == 0) return; _charge(a.account, a.token0, amountIn); uint256 before = IERC20(a.token1).balanceOf(a.account); _approve(a.account, a.token0, router, amountIn); _exec( a.account, router, 0, abi.encodeCall( IAggregationRouter.swap, ( IAggregationRouter.SwapDescription({ tokenIn: a.token0, tokenOut: a.token1, recipient: a.account, amountIn: amountIn, minAmountOut: a.minOut, deadline: a.deadline, maxFeeBps: 50, partiallyFillable: false }), route ) ) ); _requireNoResidual(a.account, a.token0, router); uint256 delivered = IERC20(a.token1).balanceOf(a.account) - before; if (delivered < a.minOut) revert DeliveredLessThanFloor(); // RETRACT WHAT THE BURN PUBLISHED FOR THE TOKEN THIS SWAP SPENT. // // `_decrease` above publishes BOTH sides of the position under this // task id. The swap then spends all of token0 and publishes only // token1, so the token0 slot was left holding a number that is no // longer this step's output — it is money that has already gone to the // router. A later step naming `amountFrom = uniswap, wantAsset = // token0` would spend it a second time, out of whatever idle balance // the account happened to hold, or revert on an account that held none. // // Retracted rather than left, because "this step delivered nothing of // token0" is the true statement and the bus's own rule is that a // consumer refuses zero. `_publishDelivered` is silent on zero by // design, so this writes through the bus directly. _retractAmount(a.account, a.taskId, a.token0); _publishDelivered(a.account, a.taskId, a.token1, delivered); } function _rebalance(Act calldata a, address npm) internal returns (uint256 tokenId) { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1, uint24 fee,,,) = _position(npm, a.tokenId); // WHAT THE BURN PRODUCED. Held rather than re-read, because the cap // rule below needs the same figure twice and `_rebalance` has no stack // left for a second pair of locals (via_ir is pinned off). (uint256 got0, uint256 got1) = _burnAll(a, npm); uint256 amount0 = a.amount0 == 0 ? got0 : a.amount0; uint256 amount1 = a.amount1 == 0 ? got1 : a.amount1; _requireFunded(a, token0, token1, fee, amount0, amount1); _spendTopUp(a.account, token1, amount1, got1); _spendTopUp(a.account, token0, amount0, got0); // NO _spendCap on the burn's own proceeds. See `_spendTopUp`. // // A named amount is a different matter: `a.amount0`/`a.amount1` let a // rebalance TOP UP the new position from the account's own balance, and // that part is a spend like any other. Only the burn's proceeds are // exempt, and only up to what the burn produced. _approve(a.account, token0, npm, amount0); _approve(a.account, token1, npm, amount1); INPM.MintParams memory p; p.token0 = token0; p.token1 = token1; p.fee = fee; p.tickLower = a.tickLower; p.tickUpper = a.tickUpper; p.amount0Desired = amount0; p.amount1Desired = amount1; // NO FLOOR ON THE NEW BAND — the same arithmetic as a split's children // and a reposition's band, found beside finding 21 of the 2026-09-26 // audit. These were `a.amount0Min`/`a.amount1Min`, the BURN's floors, // and a band of a different shape takes the tokens in a different // ratio: into one skewed away from the old, the side it takes less of // falls short of any floor worth putting on the burn, and into a // one-sided band the empty side needs zero while the burn of an // in-range position needs both — so that rebalance never completed at // all. Either way the only signable burn floor was a low one, on the leg // where value can be lost. `_decreaseCollect` still holds the burn to // them, behind the TWAP gate; the band mints at that same price; what it // does not take stays in the account and `_refundTopUp` returns any // top-up it did not use. p.amount0Min = 0; p.amount1Min = 0; p.recipient = a.account; p.deadline = a.deadline; uint256 used0; uint256 used1; (tokenId, used0, used1) = _remint(a, npm, p); _refundTopUp(a.account, token1, amount1, got1, used1); _refundTopUp(a.account, token0, amount0, got0, used0); } /// NAME THE ONE REBALANCE THE POOL REFUSES IN SILENCE. /// /// A position the price has left burns into ONE token. Rebalanced without a /// top-up into a band that needs the other — one the price is inside, or /// one on the other side of it — the position manager computes zero /// liquidity and the pool's `require(amount > 0)` reverts with nothing in /// it: "execution reverted", no selector, nothing to look up. /// /// Read at SPOT, which is the price the mint is about to use, and only in /// the two directions that are exact from a tick alone: below `tickUpper` /// the band takes token0, above `tickLower` it takes token1. AT `tickLower` /// exactly the answer depends on the price inside the tick, so nothing is /// said and the pool decides — this refuses only mints that cannot succeed. function _requireFunded( Act calldata a, address token0, address token1, uint24 fee, uint256 amount0, uint256 amount1 ) internal view { address pool = IUniFactory(_venueOf(a.account, bytes32("uniswap_factory"))).getPool( token0, token1, fee ); (, int24 tick) = IUniSpot(pool).slot0(); if ((tick < a.tickUpper && amount0 == 0) || (tick > a.tickLower && amount1 == 0)) { revert BandNotFunded(); } } /// Mint the replacement position and close the operation out: no allowance /// left behind, and the new id offered to later steps in this transaction. /// /// Shared by rebalance and reposition because their tails were identical, /// and this contract has to fit inside EIP-170 — the same reason /// `_decreaseCollect` has one body. function _remint(Act calldata a, address npm, INPM.MintParams memory p) internal returns (uint256 tokenId, uint256 used0, uint256 used1) { (tokenId, used0, used1) = _mintP(a.account, npm, p); _requireNoResidual(a.account, p.token0, npm); _requireNoResidual(a.account, p.token1, npm); _publishTokenId(a.account, a.taskId, tokenId); } function _split(Act calldata a, address npm) internal returns (uint256 tokenIdLower) { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper,) = _position(npm, a.tokenId); // WHERE THE TWO CHILDREN MEET, NAMED BY THE PLAN AND NOT ASSUMED. // // This was `tickLower + (tickUpper - tickLower) / 2` and nothing else, // so a position spanning an ODD number of spacings could not be split // at all: the midpoint lands between two ticks, the position manager // refuses it from inside the pool with a bare require, and what reaches // a person is "execution reverted" with no data. No parameter could fix // it because there was no parameter. Position 5881122 on base -- // USDC/cbBTC at the 3000 tier, 69 spacings -- is one of those, and it // is an ordinary position that nothing was wrong with. // // The tick is CHECKED HERE rather than left to the pool because the // pool's refusal carries nothing. Both bounds are exclusive: a child // with zero width mints no liquidity, and asking for one is a mistake // rather than a no-op. int24 mid = _splitMid(a, fee, tickLower, tickUpper); // The burn's own floors are `a.amount0Min` / `a.amount1Min`, and // `_decreaseCollect` now REQUIRES them on every side that produces. // This is the one leg of a split where value can be lost. (uint256 amount0, uint256 amount1) = _burnAll(a, npm); // NO _spendCap. See `_spendTopUp`: a split burns one // account-owned position and mints two more, so nothing leaves. _approve(a.account, token0, npm, amount0); _approve(a.account, token1, npm, amount1); INPM.MintParams memory p; p.token0 = token0; p.token1 = token1; p.fee = fee; p.tickLower = tickLower; p.tickUpper = mid; p.amount0Desired = amount0 / 2; p.amount1Desired = amount1 / 2; // THE CHILDREN CARRY NO FLOOR, AND THE HALVED FLOORS WERE WORSE THAN // NONE. // // The two children occupy DISJOINT tick ranges, so at any spot price at // least one of them deposits ZERO of one token — `LiquidityAmounts` // uses only `amount0` when `sqrtPrice <= sqrtRatio(tickLower)` and only // `amount1` when `sqrtPrice >= sqrtRatio(tickUpper)`. A halved floor on // that side is therefore unreachable, and `amount0Min / 2` guaranteed // `Price slippage check` for any signed floor above 1. The repo's own // `test_SplitChildrenHonourHalfTheSignedMintFloors` asserted exactly // that revert and read it as the floor working. // // Because the SAME two fields are the BURN's floors, every split in // production therefore had to sign a zero floor on the side that // mattered — which left the burn, the only leg where value can actually // be lost, completely unbounded. Dropping the mint floors is what makes // the signed floors usable, and `_decreaseCollect` requires them. // // 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. A mint that under-fills is // a change of shape, never a loss. p.amount0Min = 0; p.amount1Min = 0; p.recipient = a.account; p.deadline = a.deadline; (tokenIdLower,,) = _mintP(a.account, npm, p); p.tickLower = mid; p.tickUpper = tickUpper; p.amount0Desired = amount0 - amount0 / 2; p.amount1Desired = amount1 - amount1 / 2; _mintP(a.account, npm, p); // NOTHING TO REFUND: a split charges the epoch nothing, so a leftover // here is the account's own tokens sitting where they already were. _requireNoResidual(a.account, token0, npm); _requireNoResidual(a.account, token1, npm); _publishTokenId(a.account, a.taskId, tokenIdLower); } struct Repos { address npm; address token0; address token1; uint24 fee; uint256 amount0; uint256 amount1; } function _reposition(Act calldata a, address npm) internal returns (uint256) { _requireOwner(a.account, npm, a.tokenId); return _repositionMint(a, _unwindRepos(a, npm)); } function _unwindRepos(Act calldata a, address npm) internal returns (Repos memory r) { r.npm = npm; (r.token0, r.token1, r.fee,,,) = _position(npm, a.tokenId); (r.amount0, r.amount1) = _burnAll(a, npm); } function _repositionMint(Act calldata a, Repos memory r) internal returns (uint256 tokenId) { address factory = _venueOf(a.account, bytes32("uniswap_factory")); bool token0Only = r.amount0 > 0 && r.amount1 == 0; (int24 tick, int24 spacing) = UniTick.bandAnchor(factory, r.token0, r.token1, r.fee, token0Only); (int24 lower, int24 upper) = UniTick.outOfRangeBand(tick, spacing, token0Only); // NO _spendCap: every token here came out of the position this call just // burned. See `_spendTopUp`. _approve(a.account, r.token0, r.npm, r.amount0); _approve(a.account, r.token1, r.npm, r.amount1); INPM.MintParams memory p; p.token0 = r.token0; p.token1 = r.token1; p.fee = r.fee; p.tickLower = lower; p.tickUpper = upper; p.amount0Desired = r.amount0; p.amount1Desired = r.amount1; // `_rebalance` copies these two onto its remint and this did not, so a // reposition — which unwinds and then mints a ONE-SPACING out-of-range // band — accepted any fill at all, including a hundred per cent of one // token. The narrower the band, the more a moved price costs, so this // was the operation that needed the floor most. // NO FLOOR ON THE RE-MINT, for the same arithmetic as a split's // children, and it bites harder here. // // A reposition mints into a ONE-SPACING band deliberately placed on one // side of the price — that is what "get out of the way" means. So the // band deposits exactly one token and ZERO of the other, and any // non-zero floor on the empty side is `Price slippage check` every // time. Since these are the same two fields as the BURN's floors, a // reposition in production had to sign zeroes, which left the burn — the // only leg where value can be lost — unbounded. // // `_decreaseCollect` requires them on the burn now, and the tokens the // one-sided band cannot take stay in the account — where they already // were. `test_RepositionFromSingleSided` is the case that proves the // floor could not live here. p.amount0Min = 0; p.amount1Min = 0; p.recipient = a.account; p.deadline = a.deadline; (tokenId,,) = _remint(a, r.npm, p); } function _closeAtTick(Act calldata a, address npm) internal { _requireOwner(a.account, npm, a.tokenId); (address token0, address token1, uint24 fee,,,) = _position(npm, a.tokenId); address factory = _venueOf(a.account, bytes32("uniswap_factory")); (int24 current,) = UniTick.currentTick(factory, token0, token1, fee); int24 slip = UniTick.capSlippage(a.tickSlippage); if (current < a.tick - slip || current > a.tick + slip) { revert TickOutOfRange(); } _decrease(a, npm); } }