diff --git a/snapshots/PermissionedV4RouterTest.json b/snapshots/PermissionedV4RouterTest.json index 7dc0dcd5d..c7a18c594 100644 --- a/snapshots/PermissionedV4RouterTest.json +++ b/snapshots/PermissionedV4RouterTest.json @@ -1,5 +1,5 @@ { - "PermissionedV4Router_ExactIn3Hops_OrdinaryTokens_DistinctAssets": "262218", + "PermissionedV4Router_ExactIn3Hops_OrdinaryTokens_DistinctAssets": "262243", "PermissionedV4Router_ExactInputSingle_OrdinaryTokens": "152683", "PermissionedV4Router_ExactInputSingle_PermissionedTokens": "257034" } \ No newline at end of file diff --git a/snapshots/PosMGasTest.json b/snapshots/PosMGasTest.json index c0ce83b4f..37a78021f 100644 --- a/snapshots/PosMGasTest.json +++ b/snapshots/PosMGasTest.json @@ -1,43 +1,43 @@ { - "PositionManager_burn_empty": "51259", - "PositionManager_burn_empty_native": "51259", - "PositionManager_burn_nonEmpty_native_withClose": "130336", - "PositionManager_burn_nonEmpty_native_withTakePair": "129724", - "PositionManager_burn_nonEmpty_withClose": "137241", - "PositionManager_burn_nonEmpty_withTakePair": "136630", - "PositionManager_collect_native": "151005", - "PositionManager_collect_sameRange": "159637", - "PositionManager_collect_withClose": "159637", - "PositionManager_collect_withTakePair": "158885", - "PositionManager_decreaseLiquidity_native": "116574", - "PositionManager_decreaseLiquidity_withClose": "125206", - "PositionManager_decreaseLiquidity_withTakePair": "124454", - "PositionManager_decrease_burnEmpty": "140182", - "PositionManager_decrease_burnEmpty_native": "133276", - "PositionManager_decrease_sameRange_allLiquidity": "137922", - "PositionManager_decrease_take_take": "125763", - "PositionManager_increaseLiquidity_erc20_withClose": "163979", - "PositionManager_increaseLiquidity_erc20_withSettlePair": "162855", - "PositionManager_increaseLiquidity_native": "146850", - "PositionManager_increase_autocompoundExactUnclaimedFees": "141404", - "PositionManager_increase_autocompoundExcessFeesCredit": "182872", - "PositionManager_increase_autocompound_clearExcess": "152726", - "PositionManager_mint_native": "370903", - "PositionManager_mint_nativeWithSweep_withClose": "379541", - "PositionManager_mint_nativeWithSweep_withSettlePair": "378637", - "PositionManager_mint_onSameTickLower": "322597", - "PositionManager_mint_onSameTickUpper": "323239", - "PositionManager_mint_sameRange": "248821", - "PositionManager_mint_settleWithBalance_sweep": "424132", - "PositionManager_mint_warmedPool_differentRange": "328615", - "PositionManager_mint_withClose": "425272", - "PositionManager_mint_withSettlePair": "424234", - "PositionManager_multicall_initialize_mint": "461635", + "PositionManager_burn_empty": "51284", + "PositionManager_burn_empty_native": "51284", + "PositionManager_burn_nonEmpty_native_withClose": "130360", + "PositionManager_burn_nonEmpty_native_withTakePair": "129749", + "PositionManager_burn_nonEmpty_withClose": "137266", + "PositionManager_burn_nonEmpty_withTakePair": "136655", + "PositionManager_collect_native": "151036", + "PositionManager_collect_sameRange": "159668", + "PositionManager_collect_withClose": "159668", + "PositionManager_collect_withTakePair": "158916", + "PositionManager_decreaseLiquidity_native": "116605", + "PositionManager_decreaseLiquidity_withClose": "125237", + "PositionManager_decreaseLiquidity_withTakePair": "124485", + "PositionManager_decrease_burnEmpty": "140232", + "PositionManager_decrease_burnEmpty_native": "133326", + "PositionManager_decrease_sameRange_allLiquidity": "137953", + "PositionManager_decrease_take_take": "125794", + "PositionManager_increaseLiquidity_erc20_withClose": "164010", + "PositionManager_increaseLiquidity_erc20_withSettlePair": "162886", + "PositionManager_increaseLiquidity_native": "146881", + "PositionManager_increase_autocompoundExactUnclaimedFees": "141435", + "PositionManager_increase_autocompoundExcessFeesCredit": "182903", + "PositionManager_increase_autocompound_clearExcess": "152757", + "PositionManager_mint_native": "370934", + "PositionManager_mint_nativeWithSweep_withClose": "379572", + "PositionManager_mint_nativeWithSweep_withSettlePair": "378668", + "PositionManager_mint_onSameTickLower": "322628", + "PositionManager_mint_onSameTickUpper": "323270", + "PositionManager_mint_sameRange": "248852", + "PositionManager_mint_settleWithBalance_sweep": "424163", + "PositionManager_mint_warmedPool_differentRange": "328646", + "PositionManager_mint_withClose": "425303", + "PositionManager_mint_withSettlePair": "424265", + "PositionManager_multicall_initialize_mint": "461666", "PositionManager_permit": "79196", - "PositionManager_permit_secondPosition": "62096", + "PositionManager_permit_secondPosition": "62084", "PositionManager_permit_twice": "44984", "PositionManager_subscribe": "88007", "PositionManager_unsubscribe": "62718", - "position manager initcode hash (without constructor params, as uint256)": "22371412936681345072746904414719301883968703142882969386589328934051094972573", - "positionManager bytecode size": "19965" + "position manager initcode hash (without constructor params, as uint256)": "31566190650096491758703851573109861444803428524943761846064582565711289449983", + "positionManager bytecode size": "20013" } \ No newline at end of file diff --git a/snapshots/QuoterTest.json b/snapshots/QuoterTest.json index 6acdcbbd4..2edaa70b0 100644 --- a/snapshots/QuoterTest.json +++ b/snapshots/QuoterTest.json @@ -1,15 +1,15 @@ { - "Quoter_exactInputSingle_oneForZero_multiplePositions": "146134", - "Quoter_exactInputSingle_zeroForOne_multiplePositions": "152349", - "Quoter_exactOutputSingle_oneForZero": "79477", - "Quoter_exactOutputSingle_zeroForOne": "84722", - "Quoter_quoteExactInput_oneHop_1TickLoaded": "123688", - "Quoter_quoteExactInput_oneHop_initializedAfter": "148320", - "Quoter_quoteExactInput_oneHop_startingInitialized": "81595", - "Quoter_quoteExactInput_twoHops": "206640", - "Quoter_quoteExactOutput_oneHop_1TickLoaded": "123181", - "Quoter_quoteExactOutput_oneHop_2TicksLoaded": "153836", - "Quoter_quoteExactOutput_oneHop_initializedAfter": "123208", - "Quoter_quoteExactOutput_oneHop_startingInitialized": "99502", - "Quoter_quoteExactOutput_twoHops": "206368" + "Quoter_exactInputSingle_oneForZero_multiplePositions": "146185", + "Quoter_exactInputSingle_zeroForOne_multiplePositions": "152407", + "Quoter_exactOutputSingle_oneForZero": "79521", + "Quoter_exactOutputSingle_zeroForOne": "84774", + "Quoter_quoteExactInput_oneHop_1TickLoaded": "123746", + "Quoter_quoteExactInput_oneHop_initializedAfter": "148371", + "Quoter_quoteExactInput_oneHop_startingInitialized": "81646", + "Quoter_quoteExactInput_twoHops": "206749", + "Quoter_quoteExactOutput_oneHop_1TickLoaded": "123262", + "Quoter_quoteExactOutput_oneHop_2TicksLoaded": "153917", + "Quoter_quoteExactOutput_oneHop_initializedAfter": "123289", + "Quoter_quoteExactOutput_oneHop_startingInitialized": "99583", + "Quoter_quoteExactOutput_twoHops": "206524" } \ No newline at end of file diff --git a/snapshots/V4RouterTest.json b/snapshots/V4RouterTest.json index 66483dc7c..9139d5a8e 100644 --- a/snapshots/V4RouterTest.json +++ b/snapshots/V4RouterTest.json @@ -1,27 +1,27 @@ { - "V4Router_Bytecode": "10803", - "V4Router_ExactIn1Hop_nativeIn": "123054", - "V4Router_ExactIn1Hop_nativeOut": "121521", - "V4Router_ExactIn1Hop_oneForZero": "130393", - "V4Router_ExactIn1Hop_zeroForOne": "136918", - "V4Router_ExactIn2Hops": "194220", - "V4Router_ExactIn2Hops_nativeIn": "180356", - "V4Router_ExactIn3Hops": "251550", - "V4Router_ExactIn3Hops_nativeIn": "237686", + "V4Router_Bytecode": "10928", + "V4Router_ExactIn1Hop_nativeIn": "123079", + "V4Router_ExactIn1Hop_nativeOut": "121546", + "V4Router_ExactIn1Hop_oneForZero": "130418", + "V4Router_ExactIn1Hop_zeroForOne": "136943", + "V4Router_ExactIn2Hops": "194245", + "V4Router_ExactIn2Hops_nativeIn": "180381", + "V4Router_ExactIn3Hops": "251575", + "V4Router_ExactIn3Hops_nativeIn": "237711", "V4Router_ExactInputSingle": "135038", "V4Router_ExactInputSingle_nativeIn": "121174", "V4Router_ExactInputSingle_nativeOut": "119619", - "V4Router_ExactOut1Hop_nativeIn_sweepETH": "129686", - "V4Router_ExactOut1Hop_nativeOut": "123107", - "V4Router_ExactOut1Hop_oneForZero": "131979", - "V4Router_ExactOut1Hop_zeroForOne": "136604", - "V4Router_ExactOut2Hops": "193342", - "V4Router_ExactOut2Hops_nativeIn": "186424", - "V4Router_ExactOut3Hops": "250123", - "V4Router_ExactOut3Hops_nativeIn": "243205", - "V4Router_ExactOut3Hops_nativeOut": "227374", + "V4Router_ExactOut1Hop_nativeIn_sweepETH": "129727", + "V4Router_ExactOut1Hop_nativeOut": "123148", + "V4Router_ExactOut1Hop_oneForZero": "132020", + "V4Router_ExactOut1Hop_zeroForOne": "136645", + "V4Router_ExactOut2Hops": "193383", + "V4Router_ExactOut2Hops_nativeIn": "186465", + "V4Router_ExactOut3Hops": "250164", + "V4Router_ExactOut3Hops_nativeIn": "243246", + "V4Router_ExactOut3Hops_nativeOut": "227415", "V4Router_ExactOutputSingle": "134618", "V4Router_ExactOutputSingle_nativeIn_sweepETH": "127700", "V4Router_ExactOutputSingle_nativeOut": "121134", - "router initcode hash (without constructor params, as uint256)": "114729708465987648700921406537732645444618493910742794723401238925364218137616" + "router initcode hash (without constructor params, as uint256)": "1934144615712005979990355159924719464454373626622928348108804334780921485626" } \ No newline at end of file diff --git a/src/V4Router.sol b/src/V4Router.sol index 17b215368..0d8bfe11b 100644 --- a/src/V4Router.sol +++ b/src/V4Router.sol @@ -112,6 +112,7 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver { unchecked { // Caching for gas savings uint256 pathLength = params.path.length; + if (pathLength == 0) revert EmptyPath(); uint128 amountOut; Currency currencyIn = params.currencyIn; uint128 amountIn = _mapSwapAmount(params.amountIn); @@ -151,12 +152,15 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver { } BalanceDelta delta = _swap(params.poolKey, params.zeroForOne, int256(uint256(amountOut)), params.hookData); // exact output is all-or-nothing: a pool can deliver less than requested if it runs out of - // liquidity before the price limit. Reverting on a shortfall keeps "exact output" exact; - // over-delivery (possible only via hook pools) is allowed. + // liquidity before the price limit, and never more, since v4-core folds a hook's specified-side + // delta into the amount it swaps and afterSwap can only adjust the unspecified side. Reverting + // on a shortfall keeps "exact output" exact. uint128 amountOutActual = _swapOutput(delta, params.zeroForOne); if (amountOutActual < amountOut) revert V4ExactOutputUnfilled(amountOut, amountOutActual); uint128 amountIn = _swapInput(delta, params.zeroForOne); if (amountIn > params.amountInMaximum) revert V4TooMuchRequested(params.amountInMaximum, amountIn); + // A zero-cost (fully hook-funded) swap leaves pre-funded input in the router; plans using + // payerIsUser=false or native input must return the full remaining input currency balance. // a hook can fund the whole input, leaving a positive output against a zero input. The realized // price is then infinite and clears every finite bound, so skip the division rather than panic. if (params.minHopPriceX36 != 0 && amountIn != 0) { @@ -171,6 +175,7 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver { unchecked { // Caching for gas savings uint256 pathLength = params.path.length; + if (pathLength == 0) revert EmptyPath(); uint128 amountIn; uint128 amountOut = _mapSwapAmount(params.amountOut); Currency currencyOut = params.currencyOut; @@ -211,6 +216,8 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver { // PoolManager rejects. The untouched currencies carry no delta, so settlement is a // no-op for them and amountIn of 0 trivially clears amountInMaximum below. // The upstream pools are never swapped, so their hooks never run. + // A zero-cost (fully hook-funded) route leaves pre-funded input in the router; plans using + // payerIsUser=false or native input must return the full remaining input currency balance. if (amountIn == 0) break; amountOut = amountIn; currencyOut = pathKey.intermediateCurrency; diff --git a/src/interfaces/IV4Router.sol b/src/interfaces/IV4Router.sol index 24259bd37..65a9c2c24 100644 --- a/src/interfaces/IV4Router.sol +++ b/src/interfaces/IV4Router.sol @@ -23,6 +23,8 @@ interface IV4Router is IImmutableState { error V4TooMuchRequestedPerHopSingle(uint256 minPrice, uint256 price); /// @notice Emitted when the length of the per-hop minimum price array is not zero and not equal to the path length error InvalidHopPriceLength(); + /// @notice Emitted when a multi-hop swap is given an empty path + error EmptyPath(); /// @notice Emitted when an exactOutput swap (or hop) delivers less than the requested amount, e.g. a /// pool runs out of liquidity before the price limit. Exact output is all-or-nothing. error V4ExactOutputUnfilled(uint256 amountOutRequested, uint256 amountOutReceived); diff --git a/src/lens/V4Quoter.sol b/src/lens/V4Quoter.sol index fa115ce09..e6434d46c 100644 --- a/src/lens/V4Quoter.sol +++ b/src/lens/V4Quoter.sol @@ -6,6 +6,7 @@ import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; import {IV4Quoter} from "../interfaces/IV4Quoter.sol"; import {PathKey} from "../libraries/PathKey.sol"; import {QuoterRevert} from "../libraries/QuoterRevert.sol"; @@ -19,6 +20,7 @@ import {IMsgSender} from "../interfaces/IMsgSender.sol"; /// to compute the result. They are also not gas efficient and should not be called on-chain. contract V4Quoter is IV4Quoter, BaseV4Quoter { using QuoterRevert for *; + using SafeCast for *; constructor(IPoolManager _poolManager) BaseV4Quoter(_poolManager) {} @@ -100,9 +102,9 @@ contract V4Quoter is IV4Quoter, BaseV4Quoter { pathKey = params.path[i]; (PoolKey memory poolKey, bool zeroForOne) = pathKey.getPoolAndSwapDirection(inputCurrency); - swapDelta = _swap(poolKey, zeroForOne, -int256(int128(amountIn)), pathKey.hookData); + swapDelta = _swap(poolKey, zeroForOne, -int256(uint256(amountIn)), pathKey.hookData); - amountIn = zeroForOne ? uint128(swapDelta.amount1()) : uint128(swapDelta.amount0()); + amountIn = zeroForOne ? swapDelta.amount1().toUint128() : swapDelta.amount0().toUint128(); inputCurrency = pathKey.intermediateCurrency; } // amountIn after the loop actually holds the amountOut of the trade @@ -112,10 +114,11 @@ contract V4Quoter is IV4Quoter, BaseV4Quoter { /// @dev external function called within the _unlockCallback, to simulate a single-hop exact input swap, then revert with the result function _quoteExactInputSingle(QuoteExactSingleParams calldata params) external selfOnly returns (bytes memory) { BalanceDelta swapDelta = - _swap(params.poolKey, params.zeroForOne, -int256(int128(params.exactAmount)), params.hookData); + _swap(params.poolKey, params.zeroForOne, -int256(uint256(params.exactAmount)), params.hookData); - // the output delta of a swap is positive - uint256 amountOut = params.zeroForOne ? uint128(swapDelta.amount1()) : uint128(swapDelta.amount0()); + // The output delta is positive for ordinary pools. A hook taking more than the whole output can drive it + // negative, which the cast rejects. + uint256 amountOut = params.zeroForOne ? swapDelta.amount1().toUint128() : swapDelta.amount0().toUint128(); amountOut.revertQuote(); } @@ -133,7 +136,11 @@ contract V4Quoter is IV4Quoter, BaseV4Quoter { swapDelta = _swap(poolKey, !oneForZero, int256(uint256(amountOut)), pathKey.hookData); - amountOut = oneForZero ? uint128(-swapDelta.amount1()) : uint128(-swapDelta.amount0()); + amountOut = oneForZero + ? uint256(-int256(swapDelta.amount1())).toUint128() + : uint256(-int256(swapDelta.amount0())).toUint128(); + + if (amountOut == 0) break; outputCurrency = pathKey.intermediateCurrency; } @@ -146,8 +153,11 @@ contract V4Quoter is IV4Quoter, BaseV4Quoter { BalanceDelta swapDelta = _swap(params.poolKey, params.zeroForOne, int256(uint256(params.exactAmount)), params.hookData); - // the input delta of a swap is negative so we must flip it - uint256 amountIn = params.zeroForOne ? uint128(-swapDelta.amount0()) : uint128(-swapDelta.amount1()); + // The input delta is negative for ordinary pools. A hook can fund it exactly, leaving zero; an overfunded + // positive delta is rejected by the cast. + uint256 amountIn = params.zeroForOne + ? uint256(-int256(swapDelta.amount0())).toUint128() + : uint256(-int256(swapDelta.amount1())).toUint128(); amountIn.revertQuote(); } diff --git a/src/libraries/CalldataDecoder.sol b/src/libraries/CalldataDecoder.sol index 9497f3457..5602e1013 100644 --- a/src/libraries/CalldataDecoder.sol +++ b/src/libraries/CalldataDecoder.sol @@ -330,6 +330,11 @@ library CalldataDecoder { uint256 length; assembly ("memory-safe") { // The offset of the `_arg`-th element is `32 * arg`, which stores the offset of the length pointer. + // That head word must itself lie inside `_bytes`: compare in whole words so a huge `_arg` cannot wrap. + if iszero(gt(div(_bytes.length, 0x20), _arg)) { + mstore(0, SLICE_ERROR_SELECTOR) + revert(0x1c, 4) + } // shl(5, x) is equivalent to mul(32, x) let lengthPtr := add(_bytes.offset, and(calldataload(add(_bytes.offset, shl(5, _arg))), OFFSET_OR_LENGTH_MASK)) diff --git a/test/lens/V4QuoterHookDelta.t.sol b/test/lens/V4QuoterHookDelta.t.sol new file mode 100644 index 000000000..ae682815c --- /dev/null +++ b/test/lens/V4QuoterHookDelta.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {Deploy, IV4Quoter} from "../shared/Deploy.sol"; +import {QuoterRevert} from "../../src/libraries/QuoterRevert.sol"; +import {MockArbitraryAfterSwapDeltaHook} from "../mocks/MockArbitraryAfterSwapDeltaHook.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {PoolModifyLiquidityTest} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {PathKey} from "../../src/libraries/PathKey.sol"; + +contract V4QuoterHookDeltaTest is Test, Deployers { + IV4Quoter internal quoter; + PoolModifyLiquidityTest internal positionManager; + MockArbitraryAfterSwapDeltaHook internal hook; + + Currency internal currency2; + PoolKey internal hookedKey; + address internal hookAddr; + + function setUp() public { + deployFreshManager(); + quoter = Deploy.v4Quoter(address(manager), hex"00"); + positionManager = new PoolModifyLiquidityTest(manager); + + currency0 = _deployCurrency("Token0", "TK0"); + currency1 = _deployCurrency("Token1", "TK1"); + currency2 = _deployCurrency("Token2", "TK2"); + + hookAddr = address(uint160(Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG)); + address implementation = address(new MockArbitraryAfterSwapDeltaHook(manager)); + vm.etch(hookAddr, implementation.code); + hook = MockArbitraryAfterSwapDeltaHook(hookAddr); + + hookedKey = _poolKey(currency0, currency1, hookAddr); + _setupPool(hookedKey); + _setupPool(_poolKey(currency1, currency2, address(0))); + + MockERC20(Currency.unwrap(currency0)).mint(hookAddr, 2 ** 120); + MockERC20(Currency.unwrap(currency1)).mint(hookAddr, 2 ** 120); + } + + function test_quoteExactInputSingle_hookTakesMoreThanOutput_revertsUnexpectedRevertBytes() public { + hook.setFixedUnspecifiedDelta(int128(2 ether)); + + _expectSafeCastOverflow(); + quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: true, exactAmount: 1 ether, hookData: bytes("") + }) + ); + } + + function test_quoteExactInputSingle_oneForZero_hookTakesMoreThanOutput_revertsUnexpectedRevertBytes() public { + hook.setFixedUnspecifiedDelta(int128(2 ether)); + + _expectSafeCastOverflow(); + quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: false, exactAmount: 1 ether, hookData: bytes("") + }) + ); + } + + function test_quoteExactInput_hookTakesMoreThanOutput_revertsUnexpectedRevertBytes() public { + hook.setFixedUnspecifiedDelta(int128(2 ether)); + + PathKey[] memory path = new PathKey[](2); + path[0] = PathKey(currency1, 3000, 60, IHooks(address(0)), bytes("")); + path[1] = PathKey(currency0, 3000, 60, IHooks(hookAddr), bytes("")); + + _expectSafeCastOverflow(); + quoter.quoteExactInput(IV4Quoter.QuoteExactParams({exactCurrency: currency2, path: path, exactAmount: 1 ether})); + } + + function test_quoteExactInputSingle_uint128Max_revertsSafeCastOverflow() public { + _expectSafeCastOverflow(); + quoter.quoteExactInputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: true, exactAmount: type(uint128).max, hookData: bytes("") + }) + ); + } + + function test_quoteExactOutputSingle_hookOverfundsInput_revertsUnexpectedRevertBytes() public { + hook.setSubsidizeExactOutput(1); + + _expectSafeCastOverflow(); + quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: true, exactAmount: 1 ether, hookData: bytes("") + }) + ); + } + + function test_quoteExactOutputSingle_oneForZero_hookOverfundsInput_revertsUnexpectedRevertBytes() public { + hook.setSubsidizeExactOutput(1); + + _expectSafeCastOverflow(); + quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: false, exactAmount: 1 ether, hookData: bytes("") + }) + ); + } + + function test_quoteExactOutput_hookOverfundsInput_revertsUnexpectedRevertBytes() public { + hook.setSubsidizeExactOutput(1); + + _expectSafeCastOverflow(); + quoter.quoteExactOutput(_exactOutputPathWithHookProcessedLast(1 ether)); + } + + function test_quoteExactOutputSingle_hookFundsInputExactly_quotesZeroInput() public { + hook.setSubsidizeExactOutput(0); + + (uint256 amountIn,) = quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: true, exactAmount: 1 ether, hookData: bytes("") + }) + ); + + assertEq(amountIn, 0); + } + + function test_quoteExactOutputSingle_inputDeltaInt128Min_quotesTwoTo127() public { + hook.setForceInputDeltaToMin(); + + (uint256 amountIn,) = quoter.quoteExactOutputSingle( + IV4Quoter.QuoteExactSingleParams({ + poolKey: hookedKey, zeroForOne: true, exactAmount: 1 ether, hookData: bytes("") + }) + ); + + assertEq(amountIn, 2 ** 127); + } + + function test_quoteExactOutput_fullyFundedHop_quotesZeroInput() public { + hook.setSubsidizeExactOutput(0); + + (uint256 amountIn,) = quoter.quoteExactOutput(_exactOutputPathWithHookProcessedFirst(1 ether)); + + assertEq(amountIn, 0); + } + + function _exactOutputPathWithHookProcessedLast(uint128 amountOut) + private + view + returns (IV4Quoter.QuoteExactParams memory params) + { + PathKey[] memory path = new PathKey[](2); + path[0] = PathKey(currency0, 3000, 60, IHooks(hookAddr), bytes("")); + path[1] = PathKey(currency1, 3000, 60, IHooks(address(0)), bytes("")); + return IV4Quoter.QuoteExactParams({exactCurrency: currency2, path: path, exactAmount: amountOut}); + } + + function _exactOutputPathWithHookProcessedFirst(uint128 amountOut) + private + view + returns (IV4Quoter.QuoteExactParams memory params) + { + PathKey[] memory path = new PathKey[](2); + path[0] = PathKey(currency2, 3000, 60, IHooks(address(0)), bytes("")); + path[1] = PathKey(currency1, 3000, 60, IHooks(hookAddr), bytes("")); + return IV4Quoter.QuoteExactParams({exactCurrency: currency0, path: path, exactAmount: amountOut}); + } + + function _expectSafeCastOverflow() private { + vm.expectRevert( + abi.encodeWithSelector( + QuoterRevert.UnexpectedRevertBytes.selector, abi.encodeWithSelector(SafeCast.SafeCastOverflow.selector) + ) + ); + } + + function _deployCurrency(string memory name, string memory symbol) private returns (Currency currency) { + MockERC20 token = new MockERC20(name, symbol, 18); + token.mint(address(this), 2 ** 120); + return Currency.wrap(address(token)); + } + + function _poolKey(Currency currencyA, Currency currencyB, address hookAddress) + private + pure + returns (PoolKey memory) + { + if (Currency.unwrap(currencyA) > Currency.unwrap(currencyB)) { + (currencyA, currencyB) = (currencyB, currencyA); + } + return PoolKey(currencyA, currencyB, 3000, 60, IHooks(hookAddress)); + } + + function _setupPool(PoolKey memory key) private { + manager.initialize(key, SQRT_PRICE_1_1); + MockERC20(Currency.unwrap(key.currency0)).approve(address(positionManager), type(uint256).max); + MockERC20(Currency.unwrap(key.currency1)).approve(address(positionManager), type(uint256).max); + positionManager.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 200 ether, 0), bytes("")); + } +} diff --git a/test/libraries/CalldataDecoder.t.sol b/test/libraries/CalldataDecoder.t.sol index 4c2a658af..20581f1c5 100644 --- a/test/libraries/CalldataDecoder.t.sol +++ b/test/libraries/CalldataDecoder.t.sol @@ -431,4 +431,36 @@ contract CalldataDecoderTest is Test { result[i] = params[i]; } } + + function test_fuzz_toBytes_roundTrips(uint256 word, bytes calldata data) public view { + bytes memory params = abi.encode(word, data); + assertEq(decoder.toBytes(params, 1), data); + } + + function test_fuzz_toBytes_revertsWhenHeadWordIsOutOfBounds(bytes calldata params, uint256 arg) public { + // the head word at index `arg` lies outside the slice, including indices large enough to wrap 32 * arg + vm.assume(arg >= params.length / 32); + + vm.expectRevert(CalldataDecoder.SliceOutOfBounds.selector); + decoder.toBytes(params, arg); + } + + function test_toBytes_revertsWhenHeadWordIsImmediatelyOutOfBounds() public { + vm.expectRevert(CalldataDecoder.SliceOutOfBounds.selector); + decoder.toBytes(abi.encode(uint256(0)), 1); + } + + function test_toBytes_revertsWhenHeadWordOffsetWraps() public { + vm.expectRevert(CalldataDecoder.SliceOutOfBounds.selector); + decoder.toBytes(abi.encode(bytes("")), 2 ** 251); + } + + function test_decodeBurnParams_revertsWhenHookDataHeadIsOutOfBounds() public { + // three static words and no fourth: the head word holding the hookData offset is missing, so the + // decoder must not read it from whatever follows the slice + bytes memory params = abi.encode(uint256(1), uint128(2), uint128(3)); + + vm.expectRevert(CalldataDecoder.SliceOutOfBounds.selector); + decoder.decodeBurnParams(params); + } } diff --git a/test/mocks/MockArbitraryAfterSwapDeltaHook.sol b/test/mocks/MockArbitraryAfterSwapDeltaHook.sol new file mode 100644 index 000000000..d378056bb --- /dev/null +++ b/test/mocks/MockArbitraryAfterSwapDeltaHook.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseTestHooks} from "@uniswap/v4-core/src/test/BaseTestHooks.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {CurrencySettler} from "@uniswap/v4-core/test/utils/CurrencySettler.sol"; + +/// @notice Returns a configured after-swap delta on the unspecified currency, settling or taking the matching amount. +/// It can also fund an exact-output input completely, with an optional additional amount. +contract MockArbitraryAfterSwapDeltaHook is BaseTestHooks { + using CurrencySettler for Currency; + + IPoolManager public immutable manager; + + bool public subsidizeExactOutput; + bool public forceInputDeltaToMin; + int128 public fixedUnspecifiedDelta; + uint128 public extraWei; + + constructor(IPoolManager _manager) { + manager = _manager; + } + + modifier onlyPoolManager() { + require(msg.sender == address(manager), "not manager"); + _; + } + + function setFixedUnspecifiedDelta(int128 _fixedUnspecifiedDelta) external { + subsidizeExactOutput = false; + forceInputDeltaToMin = false; + fixedUnspecifiedDelta = _fixedUnspecifiedDelta; + } + + function setSubsidizeExactOutput(uint128 _extraWei) external { + subsidizeExactOutput = true; + forceInputDeltaToMin = false; + extraWei = _extraWei; + } + + function setForceInputDeltaToMin() external { + subsidizeExactOutput = false; + forceInputDeltaToMin = true; + } + + function afterSwap( + address, /* sender */ + PoolKey calldata key, + SwapParams calldata params, + BalanceDelta delta, + bytes calldata /* hookData */ + ) + external + override + onlyPoolManager + returns (bytes4, int128) + { + Currency unspecifiedCurrency = params.zeroForOne == (params.amountSpecified < 0) ? key.currency1 : key.currency0; + + int128 hookDelta = fixedUnspecifiedDelta; + bool settleHookDelta = true; + if (subsidizeExactOutput) { + require(params.amountSpecified > 0, "exact output only"); + int128 inputDelta = params.zeroForOne ? delta.amount0() : delta.amount1(); + uint256 amount = uint256(-int256(inputDelta)) + extraWei; + require(amount <= uint256(uint128(type(int128).max)), "subsidy overflows int128"); + hookDelta = -int128(int256(amount)); + } else if (forceInputDeltaToMin) { + require(params.amountSpecified > 0, "exact output only"); + int128 inputDelta = params.zeroForOne ? delta.amount0() : delta.amount1(); + hookDelta = int128(int256(inputDelta) - int256(type(int128).min)); + // The quoter reverts the simulation before PoolManager checks unlock solvency. Avoid taking this + // deliberately enormous hook credit so the test can reach the caller's int128 minimum delta. + settleHookDelta = false; + } + + if (settleHookDelta && hookDelta > 0) { + unspecifiedCurrency.take(manager, address(this), uint128(hookDelta), false); + } else if (settleHookDelta && hookDelta < 0) { + unspecifiedCurrency.settle(manager, address(this), uint256(-int256(hookDelta)), false); + } + + return (IHooks.afterSwap.selector, hookDelta); + } +} diff --git a/test/mocks/MockCalldataDecoder.sol b/test/mocks/MockCalldataDecoder.sol index 3cc261aea..6719316c8 100644 --- a/test/mocks/MockCalldataDecoder.sol +++ b/test/mocks/MockCalldataDecoder.sol @@ -180,6 +180,10 @@ contract MockCalldataDecoder { }); } + function toBytes(bytes calldata params, uint256 arg) external pure returns (bytes memory) { + return params.toBytes(arg); + } + function decodeUint256(bytes calldata params) external pure returns (uint256) { return params.decodeUint256(); } diff --git a/test/router/V4Router.t.sol b/test/router/V4Router.t.sol index 4a5d201e2..e86566ac3 100644 --- a/test/router/V4Router.t.sol +++ b/test/router/V4Router.t.sol @@ -11,6 +11,7 @@ import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PathKey} from "../../src/libraries/PathKey.sol"; import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; contract V4RouterTest is RoutingTestHelpers { @@ -1139,4 +1140,37 @@ contract V4RouterTest is RoutingTestHelpers { assertEq(inputBalanceBefore - inputBalanceAfter, expectedAmountIn); assertEq(outputBalanceAfter - outputBalanceBefore, amountOut); } + + /*////////////////////////////////////////////////////////////// + EMPTY PATH + //////////////////////////////////////////////////////////////*/ + + function test_swapExactIn_revertsOnEmptyPath() public { + IV4Router.ExactInputParams memory params; + params.currencyIn = currency0; + params.path = new PathKey[](0); + params.minHopPriceX36 = new uint256[](0); + params.amountIn = uint128(1 ether); + + plan = plan.add(Actions.SWAP_EXACT_IN, abi.encode(params)); + bytes memory data = plan.finalizeSwap(currency0, currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert(IV4Router.EmptyPath.selector); + router.executeActions(data); + } + + function test_swapExactOut_revertsOnEmptyPath() public { + IV4Router.ExactOutputParams memory params; + params.currencyOut = currency1; + params.path = new PathKey[](0); + params.minHopPriceX36 = new uint256[](0); + params.amountOut = uint128(1 ether); + params.amountInMaximum = type(uint128).max; + + plan = plan.add(Actions.SWAP_EXACT_OUT, abi.encode(params)); + bytes memory data = plan.finalizeSwap(currency0, currency1, ActionConstants.MSG_SENDER); + + vm.expectRevert(IV4Router.EmptyPath.selector); + router.executeActions(data); + } }