Skip to content

fix: tolerate hook-funded input on exact-output swaps - #584

Open
dianakocsis wants to merge 4 commits into
mainfrom
fix/hook-funded-exact-output
Open

fix: tolerate hook-funded input on exact-output swaps#584
dianakocsis wants to merge 4 commits into
mainfrom
fix/hook-funded-exact-output

Conversation

@dianakocsis

@dianakocsis dianakocsis commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

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 state is solvent — the hook settles the debt inside the unlock — and core permits it explicitly:

  • Hooks.sol:311// the caller has to pay for (or receive) the hook's delta
  • IHooks.sol:114 — the afterSwap return is documented as "negative: the hook owes/sent currency"

But it is a shape no ordinary pool produces, where positive output always implied a strictly negative input. V4Router assumed that invariant, so a legitimate fully-sponsored route reverted.

Exact output is the only direction affected, because it is the only one that has to discover its input by reading a delta. Exact input divides by the caller's own amountIn parameter, so it can neither divide by zero nor read the sign that overflows.

The two failures

1. Division by zero. The per-hop price guard computes execution price as output ÷ 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 _swapExactOutput whenever minHopPriceX36 was non-empty (the multihop guard is gated on array presence, not on each bound's value, so it fired even when that hop's own bound was 0).

2. Zero propagation. 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.sol:193 rejects amountSpecified == 0 with SwapAmountCannotBeZero, unwinding the whole unlock.

Neither risks funds — the unlock is atomic — but a caller loses gas and the route.

Fix

Three lines:

if (params.minHopPriceX36 != 0 && amountIn != 0)   // skip the division; infinite price clears any bound
if (perHopPriceLength != 0 && amountIn != 0)       // same, multihop
if (amountIn == 0) break;                          // nothing upstream left to produce

Skipping the division is correct rather than defensive: an infinite realized price genuinely satisfies every finite minimum. Breaking is likewise not a shortcut — if a hop consumes nothing, the upstream hops have nothing to produce, and _take/_settle already no-op on zero (DeltaResolver.sol:27,38), so untouched currencies settle cleanly and an amountIn of 0 trivially clears amountInMaximum.

The fill check runs before the zero-input branch, so all-or-nothing still holds: a funded hop that underfills still reverts V4ExactOutputUnfilled.

Scope decisions

Over-funding is deliberately not supported. A hook paying more than the cost leaves a credit on the input currency, which still reverts SafeCastOverflow on the negation, exactly as on main. The surplus has no defined owner in a route — collecting it needs a TAKE-based plan no integrator writes today, and in multihop it can land on an intermediate currency the caller never asked to receive. Relaxing this later is non-breaking; shipping ownership semantics and walking them back would not be. Naming the condition would have meant adding an error to IV4Router for zero behavior change, so the interface is untouched and the reasoning lives on _swapInput instead.

Exact input is untouched. Verified structurally: _swapInput is called only from the two exact-output functions.

Corrected two stale comments. // The output delta will always be positive, except for when interacting with certain hook pools named a case the code does not handle — a hook taking more than the whole output leaves a negative delta and the cast in _swapOutput rejects it. Now documented as unsupported rather than reading like reassurance. Handling it properly is follow-up work.

Tests

10 tests, and each of the three fixes has a test that fails without it (verified by reverting src/V4Router.sol and re-running):

test fails without fix as
..._withMinHopPrice_succeeds panic 0x12
..._multiHop_...withPerHopPrices_succeeds panic 0x12
..._multiHop_...skipsUpstreamHops SwapAmountCannotBeZero
..._threeHop_hookFundsMiddleHop_... SwapAmountCannotBeZero
testFuzz_...onlyFullSubsidyClearsPriceBound panic 0x12

The fuzz test pins the boundary from both sides: with an unsatisfiable price bound, only a full subsidy clears it, because only then is the price infinite. Remaining tests are regression and characterization guards.

MockFullySubsidizingHook absorbs a share of whatever the pool actually charged — the existing DeltaReturningHook returns a preset amount, which cannot land the input on exactly zero. It is exact-output-only by construction (the afterSwap return applies to the unspecified currency, which is the input side only for exact output) and reverts rather than misreporting if misused.

Gas

+26 single-hop, +54/hop multihop, +96 bytes. Exact-input paths unchanged.

Note on the second commit

chore: regenerate stale Quoter gas snapshots is unrelated to this fix. CI runs FORGE_SNAPSHOT_CHECK=true, and the committed Quoter numbers were already stale on the base, so the check failed regardless of this change (confirmed by stashing the router change and re-running — the drift persists, and it moves exactInput entries this PR never touches). Kept as its own commit so it can be dropped or cherry-picked independently.

Question for reviewers

Is the multihop break acceptable? When a hop is fully funded, the upstream hops the caller specified silently do not execute. I believe it is correct — there is genuinely nothing for them to produce, the caller receives their exact output, pays nothing, and no currency is left unsettled — but it is a real behavior change worth arguing rather than waving through. test_exactOutput_threeHop_hookFundsMiddleHop_skipsRemainingHops demonstrates exactly what it does to a longer route.

Secondary: does anyone have a hook that deliberately over-pays? That is the case left unsupported above.

🤖 Generated with Claude Code

dianakocsis and others added 2 commits August 5, 2026 12:51
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>
Not related to the hook-funded fix in the previous commit. The committed
Quoter numbers on fix/exact-output-underfill no longer match what the code
produces, and CI runs with FORGE_SNAPSHOT_CHECK=true, so the check fails
regardless of any change made here:

  Error: Snapshots differ from previous run
  - [Quoter_quoteExactOutput_twoHops] 204880 -> 207219

Confirmed independent of this branch by stashing the router change and
re-running: the drift is still there. Both exactInput and exactOutput entries
move, while this branch touches only exact-output paths, so it cannot be the
cause.

Kept as its own commit so it can be cherry-picked onto
fix/exact-output-underfill, where the drift actually lives. If it is fixed
there instead, drop this commit rather than merging both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dianakocsis
dianakocsis force-pushed the fix/hook-funded-exact-output branch from df31027 to a1bde74 Compare August 5, 2026 16:53
@datadog-official

This comment has been minimized.

Long afterSwap signature wraps onto separate lines. Only this file is
touched: forge fmt locally also wants to reflow
test/position-managers/PositionManager.t.sol, but that is an artifact of
running 1.5.1 against CI's pinned 1.4.3, and CI flagged only this file.
Reformatting it here would add an unrelated file and fail 1.4.3 anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dianakocsis dianakocsis changed the title Fix/hook funded exact output fix: tolerate hook-funded input on exact-output swaps Aug 5, 2026
@staccDOTsol

Copy link
Copy Markdown

quickquick

This reverts commit a1bde74. The Quoter snapshots were never stale --
I misread a local toolchain difference as drift.

CI pins Foundry v1.3.6 for tests; this machine runs 1.5.1, and the two
measure Quoter gas differently. Locally FORGE_SNAPSHOT_CHECK failed against
the committed values, which looked like drift on the base branch. It was not:
CI regenerates exactly the values already on main, e.g.

  [Quoter_quoteExactInput_oneHop_initializedAfter] 148867 -> 147573

where 148867 was my regenerated value and 147573 is main's, which CI
reproduces. Only Quoter entries diverge between the two versions;
V4RouterTest.json matches, so the gas numbers for this fix are unaffected.

Snapshot changes here should be verified against CI, not this machine,
unless the local toolchain is pinned to v1.3.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants