Skip to content

Commit d4a3f26

Browse files
dianakocsisclaude
andcommitted
fix: tolerate hook-funded input on exact-output swaps
A v4 hook can pay a swap's input on the caller's behalf. When it covers the cost in full, PoolManager hands the router the requested positive output against an input delta of exactly zero. That is solvent -- the hook settles the debt inside the unlock -- and core permits it explicitly: Hooks.sol:311 notes the caller "has to pay for (or receive) the hook's delta", and IHooks documents a negative afterSwap return as the hook owing/sending currency. But it is a shape no ordinary pool produces, where positive output always implied strictly negative input. Exact output is the only direction that has to DISCOVER its input by reading a delta, so it is the only one affected: - The per-hop price guard divides output by input. A zero input panics 0x12, even though the realized price is then infinite and clears every finite bound it is checked against. Hit _swapExactOutputSingle and, because the multihop guard is gated on array presence rather than each bound's value, _swapExactOutput whenever minHopPriceX36 was non-empty. - The multihop loop runs backwards, feeding each hop's input to the previous hop as its required output. A funded hop propagated zero, and PoolManager rejects amountSpecified == 0 with SwapAmountCannotBeZero. Skip the division when the input is zero, and stop the backward loop when a hop consumes nothing -- the upstream hops have nothing left to produce, and _take/_settle already no-op on zero, so untouched currencies settle cleanly and an amountIn of 0 trivially clears amountInMaximum. Over-funding (a hook paying MORE than the cost, leaving a credit whose owner is undefined in a route) is deliberately left unsupported and still reverts SafeCastOverflow on the negation, as it does today. Naming that condition would mean adding an error to IV4Router for no behavior change, so the interface is untouched; the reasoning is recorded on _swapInput instead. The fill check runs before the zero-input branch, so all-or-nothing still holds: a funded hop that underfills reverts V4ExactOutputUnfilled as before. Exact input is unaffected -- its divisor is the caller's own amountIn, never a delta, so it can neither divide by zero nor read the sign that overflows. Also corrects the "output delta will always be positive" comments, which named a hook case the code does not actually handle: a hook taking more than the whole output leaves a negative delta and the cast in _swapOutput rejects it. Documented rather than fixed, since exact input is out of scope here. Gas +26 single-hop, +54/hop multihop, +96 bytes. Snapshots regenerated (isolate mode). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 545a5d2 commit d4a3f26

4 files changed

Lines changed: 385 additions & 18 deletions

File tree

snapshots/V4RouterTest.json

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"V4Router_Bytecode": "10415",
2+
"V4Router_Bytecode": "10511",
33
"V4Router_ExactIn1Hop_nativeIn": "122326",
44
"V4Router_ExactIn1Hop_nativeOut": "120793",
55
"V4Router_ExactIn1Hop_oneForZero": "129665",
@@ -11,17 +11,17 @@
1111
"V4Router_ExactInputSingle": "134382",
1212
"V4Router_ExactInputSingle_nativeIn": "120518",
1313
"V4Router_ExactInputSingle_nativeOut": "118963",
14-
"V4Router_ExactOut1Hop_nativeIn_sweepETH": "128900",
15-
"V4Router_ExactOut1Hop_nativeOut": "122321",
16-
"V4Router_ExactOut1Hop_oneForZero": "131193",
17-
"V4Router_ExactOut1Hop_zeroForOne": "135818",
18-
"V4Router_ExactOut2Hops": "192674",
19-
"V4Router_ExactOut2Hops_nativeIn": "185756",
20-
"V4Router_ExactOut3Hops": "249572",
21-
"V4Router_ExactOut3Hops_nativeIn": "242654",
22-
"V4Router_ExactOut3Hops_nativeOut": "226823",
23-
"V4Router_ExactOutputSingle": "134050",
24-
"V4Router_ExactOutputSingle_nativeIn_sweepETH": "127132",
25-
"V4Router_ExactOutputSingle_nativeOut": "120566",
26-
"router initcode hash (without constructor params, as uint256)": "1906760093307030626709390179699396653508205060645061537790918706363253111413"
14+
"V4Router_ExactOut1Hop_nativeIn_sweepETH": "128954",
15+
"V4Router_ExactOut1Hop_nativeOut": "122375",
16+
"V4Router_ExactOut1Hop_oneForZero": "131247",
17+
"V4Router_ExactOut1Hop_zeroForOne": "135872",
18+
"V4Router_ExactOut2Hops": "192782",
19+
"V4Router_ExactOut2Hops_nativeIn": "185864",
20+
"V4Router_ExactOut3Hops": "249734",
21+
"V4Router_ExactOut3Hops_nativeIn": "242816",
22+
"V4Router_ExactOut3Hops_nativeOut": "226985",
23+
"V4Router_ExactOutputSingle": "134076",
24+
"V4Router_ExactOutputSingle_nativeIn_sweepETH": "127158",
25+
"V4Router_ExactOutputSingle_nativeOut": "120592",
26+
"router initcode hash (without constructor params, as uint256)": "42363725424555520568236519911959545672855231110666850371005005945997230752702"
2727
}

src/V4Router.sol

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
115115
for (uint256 i = 0; i < pathLength; i++) {
116116
pathKey = params.path[i];
117117
(PoolKey memory poolKey, bool zeroForOne) = pathKey.getPoolAndSwapDirection(currencyIn);
118-
// The output delta will always be positive, except for when interacting with certain hook pools
118+
// The output delta is positive for ordinary pools. A hook taking more than the whole
119+
// output can drive it negative, which is unsupported: _swapOutput reverts on the cast.
119120
amountOut =
120121
_swapOutput(_swap(poolKey, zeroForOne, -int256(uint256(amountIn)), pathKey.hookData), zeroForOne);
121122

@@ -147,7 +148,9 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
147148
if (amountOutActual < amountOut) revert V4ExactOutputUnfilled(amountOut, amountOutActual);
148149
uint128 amountIn = _swapInput(delta, params.zeroForOne);
149150
if (amountIn > params.amountInMaximum) revert V4TooMuchRequested(params.amountInMaximum, amountIn);
150-
if (params.minHopPriceX36 != 0) {
151+
// a hook can fund the whole input, leaving a positive output against a zero input. The realized
152+
// price is then infinite and clears every finite bound, so skip the division rather than panic.
153+
if (params.minHopPriceX36 != 0 && amountIn != 0) {
151154
uint256 priceX36 = uint256(amountOutActual) * PRECISION / amountIn;
152155
if (priceX36 < params.minHopPriceX36) {
153156
revert V4TooMuchRequestedPerHopSingle(params.minHopPriceX36, priceX36);
@@ -174,7 +177,8 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
174177
for (uint256 i = pathLength; i > 0; i--) {
175178
pathKey = params.path[i - 1];
176179
(PoolKey memory poolKey, bool oneForZero) = pathKey.getPoolAndSwapDirection(currencyOut);
177-
// The output delta will always be positive, except for when interacting with certain hook pools
180+
// The output delta is positive for ordinary pools. A hook taking more than the whole
181+
// output can drive it negative, which is unsupported: _swapOutput reverts on the cast.
178182
BalanceDelta delta = _swap(poolKey, !oneForZero, int256(uint256(amountOut)), pathKey.hookData);
179183
uint128 amountOutActual = _swapOutput(delta, !oneForZero);
180184
// Every hop must fill. PoolManager nets one delta per currency across the whole unlock,
@@ -185,11 +189,19 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
185189
}
186190
amountIn = _swapInput(delta, !oneForZero);
187191

188-
if (perHopPriceLength != 0) {
192+
// a hook can fund the whole input, leaving a positive output against a zero input. The
193+
// realized price is then infinite and clears every finite bound, so skip the division
194+
// rather than panic.
195+
if (perHopPriceLength != 0 && amountIn != 0) {
189196
uint256 priceX36 = uint256(amountOutActual) * PRECISION / amountIn;
190197
uint256 minPrice = params.minHopPriceX36[i - 1];
191198
if (priceX36 < minPrice) revert V4TooMuchRequestedPerHop(i - 1, minPrice, priceX36);
192199
}
200+
// this hop consumed nothing, so the upstream hops have nothing left to produce. Stop
201+
// here: propagating the zero would call swap with amountSpecified == 0, which
202+
// PoolManager rejects. The untouched currencies carry no delta, so settlement is a
203+
// no-op for them and amountIn of 0 trivially clears amountInMaximum below.
204+
if (amountIn == 0) break;
193205
amountOut = amountIn;
194206
currencyOut = pathKey.intermediateCurrency;
195207
}
@@ -213,13 +225,19 @@ abstract contract V4Router is IV4Router, BaseActionsRouter, DeltaResolver {
213225

214226
/// @notice The positive input amount a swap consumed, derived from its balance delta.
215227
/// @dev The spent currency's delta is negative (owed to the pool), so negate it to a positive amount.
228+
/// A hook can pay the input on the caller's behalf. Funding it exactly leaves a zero delta,
229+
/// which negates harmlessly to an input of zero and is supported. Paying MORE leaves a positive
230+
/// delta (a credit) whose owner is undefined in a route; that is intentionally unsupported and
231+
/// reverts SafeCastOverflow, since negating it wraps to ~2^256.
216232
function _swapInput(BalanceDelta delta, bool zeroForOne) private pure returns (uint128) {
217233
return (uint256(-int256(zeroForOne ? delta.amount0() : delta.amount1()))).toUint128();
218234
}
219235

220236
/// @notice The positive output amount a swap produced, derived from its balance delta. For an
221237
/// exact-output swap this is the REALIZED output, which can be less than the requested
222238
/// amount when the pool lacks the liquidity to fill it before the price limit.
239+
/// @dev A hook taking more than the whole output leaves a negative delta. That is unsupported: the
240+
/// cast reverts SafeCastOverflow rather than treating the caller as owing the output currency.
223241
function _swapOutput(BalanceDelta delta, bool zeroForOne) private pure returns (uint128) {
224242
return (zeroForOne ? delta.amount1() : delta.amount0()).toUint128();
225243
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.24;
3+
4+
import {BaseTestHooks} from "@uniswap/v4-core/src/test/BaseTestHooks.sol";
5+
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
6+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
7+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
8+
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
9+
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
10+
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
11+
import {CurrencySettler} from "@uniswap/v4-core/test/utils/CurrencySettler.sol";
12+
13+
/// @notice A hook that pays some or all of an exact-output swap's input cost out of its own balance.
14+
/// At the default full subsidy the swapper is left with the requested positive output and a
15+
/// delta of exactly zero on the input currency.
16+
/// @dev Unlike `DeltaReturningHook`, which returns a preset amount, this absorbs a share of whatever
17+
/// the pool actually charged. That is the only way to land the swapper's input delta on exactly
18+
/// zero, which is the state `V4Router` must tolerate. Requires AFTER_SWAP_FLAG and
19+
/// AFTER_SWAP_RETURNS_DELTA_FLAG. Fund the hook with the input currency before swapping.
20+
///
21+
/// EXACT OUTPUT ONLY. The afterSwap return delta applies to the UNSPECIFIED currency, which is
22+
/// the input side only for exact output. On an exact-input swap it would land on the output
23+
/// instead, so this reverts rather than misreporting which side it funded.
24+
contract MockFullySubsidizingHook is BaseTestHooks {
25+
using CurrencySettler for Currency;
26+
27+
uint256 private constant BPS_DENOMINATOR = 10_000;
28+
29+
IPoolManager public immutable manager;
30+
31+
/// @notice Share of the swapper's input this hook pays. 10_000 is the full cost; above that
32+
/// over-funds and leaves the swapper a credit.
33+
uint256 public subsidyBps = BPS_DENOMINATOR;
34+
35+
/// @notice Additional wei paid beyond `subsidyBps`, for exercising the boundary precisely.
36+
uint128 public extraWei;
37+
38+
constructor(IPoolManager _manager) {
39+
manager = _manager;
40+
}
41+
42+
modifier onlyPoolManager() {
43+
require(msg.sender == address(manager), "not manager");
44+
_;
45+
}
46+
47+
function setSubsidyBps(uint256 _subsidyBps) external {
48+
subsidyBps = _subsidyBps;
49+
}
50+
51+
function setExtraWei(uint128 _extraWei) external {
52+
extraWei = _extraWei;
53+
}
54+
55+
function afterSwap(
56+
address, /* sender */
57+
PoolKey calldata key,
58+
SwapParams calldata params,
59+
BalanceDelta delta,
60+
bytes calldata /* hookData */
61+
) external override onlyPoolManager returns (bytes4, int128) {
62+
require(params.amountSpecified > 0, "exact output only");
63+
64+
// for exact output the unspecified currency is the input side, which is the side the
65+
// afterSwap return delta applies to
66+
(Currency inputCurrency, int128 swapperInput) =
67+
params.zeroForOne ? (key.currency0, delta.amount0()) : (key.currency1, delta.amount1());
68+
69+
uint256 fullInput = swapperInput < 0 ? uint256(uint128(-swapperInput)) : 0;
70+
uint256 owed = fullInput * subsidyBps / BPS_DENOMINATOR + extraWei;
71+
require(owed <= uint256(uint128(type(int128).max)), "subsidy overflows int128");
72+
73+
// PoolManager applies `swapDelta = swapDelta - hookDelta`, so a negative hook delta moves that
74+
// much of the debt off the swapper and onto this hook, which settles it here. Paying the full
75+
// input zeroes the swapper's side; paying more leaves them a credit.
76+
if (owed != 0) inputCurrency.settle(manager, address(this), owed, false);
77+
78+
return (IHooks.afterSwap.selector, -int128(int256(owed)));
79+
}
80+
}

0 commit comments

Comments
 (0)