fix: revert on exact-output partial fills in V4Router#564
Open
ccashwell wants to merge 2 commits into
Open
Conversation
A v4 exact-output swap stops at the hardcoded global price limit, so a pool that runs out of liquidity before then delivers LESS than the requested amountOut. V4Router only bounded the input (amountInMaximum) and never checked the realized output, so an under-filled exact-output swap silently succeeded with a short delivery (and the per-hop price guard, computed from the requested amount, could pass at a worse-than- bounded realized price). Make exact output all-or-nothing: _swap returns the BalanceDelta, and _swapExactOutputSingle/_swapExactOutput assert the realized output covers the requested amount, reverting V4ExactOutputUnfilled on a shortfall (per hop for multi-hop). Over-delivery (possible only via hook pools) is allowed. Exact-input and full fills are unchanged. The two PermissionedV4Router amountInMaximum tests requested 1e18 from a pool that can deliver at most 19992 out; they relied on the old silent underfill tripping amountInMaximum incidentally. Point them at a fillable amount so they still exercise the V4TooMuchRequested guard. Gas snapshots regenerated (isolate mode) for the router bytecode change.
ccashwell
force-pushed
the
fix/exact-output-underfill
branch
from
June 17, 2026 20:33
580a8c5 to
4c81aa3
Compare
… hop The exact-output multihop loop runs backwards, so the first iteration (i == pathLength) is the last hop in the path — the one delivering the caller's final currencyOut, where amountOut equals the total requested output. That is the only delivery a partial fill can silently shrink, so it is the only one that needs an explicit V4ExactOutputUnfilled guard. An intermediate hop that underfills needs no check here: it leaves a non-zero intermediate-currency delta that fails to settle, reverting the whole swap. Gating the assert on the first iteration drops the redundant per-hop checks at a negligible (~40 gas) cost. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G51nAjHMDEJyYWW54fTtNd
hensha256
marked this pull request as ready for review
July 8, 2026 14:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A v4 exact-output swap can partial-fill:
_swaphard-codessqrtPriceLimitto the global min/max bound (MIN_SQRT_PRICE + 1/MAX_SQRT_PRICE - 1), so when a pool runs out of liquidity across the tick range the swap stops at the limit having delivered less than the requestedamountOut.V4Router's exact-output paths only bound the input (amountInMaximum) and never check the realized output, so an under-filled exact-output swap silently succeeds with a short delivery — the opposite of "exact output."This also distorted the per-hop price guard (#516):
priceX36was computed from the requestedamountOutover the actualamountIn, overstating the execution price, so on a partial fill a swap could passminHopPriceX36at a realized price below the caller's bound.Fix
Make exact output all-or-nothing in
V4Router:_swapnow returns the fullBalanceDelta; two pure helpers (_swapInput/_swapOutput) extract the realized input/output, preserving the prior reciprocal extraction exactly._swapExactOutputSingleand_swapExactOutput(per hop) assert the realized output covers the requested amount and revert a newV4ExactOutputUnfilled(amountOutRequested, amountOutReceived)on a shortfall.Behavior:
amountOutMinimumand priced on realized output).amountOut, so the check is a no-op for them (verified: all existing exact-output router tests pass unchanged).realizedOut < amountOut(revert on shortfall), not strict!=, so over-delivery is allowed — it can only arise via hook pools, is harmless (amountInMaximumstill bounds cost), and reverting on it would be hostile.Design decision for reviewers (hook pools)
This changes canonical exact-output semantics for every integrator, so it warrants a thorough review of the behavior changes. The one substantive concern is hook pools: a hook with a
BeforeSwapDelta/afterSwapDeltacan legitimately make the realized output differ from the requested amount. Under this change, a hook that delivers less than requested on an exact-output swap would now revert. This is defensible because the caller asked for an exact amount and the pool didn't provide it, but it is a behavior change such pools would observe.Two questions we should agree on before merging this:
realizedOut < amountOut(allow over-delivery) vs strictrealizedOut != amountOut(literal "exact"). This PR uses the former, but I'm not 100% on whether this is the right call.Context
Surfaced while integrating
V4Routerin a margin-trading periphery (#563), where exact-output is used to size leverage opens. That PR guards the gap at the application layer (anASSERT_FILL/IncompleteFillcheck before using the swap output). This PR is the proper root fix in the shared router; if it lands, the app-layer guard becomes redundant defense-in-depth.