Skip to content

fix: revert on exact-output partial fills in V4Router#564

Open
ccashwell wants to merge 2 commits into
mainfrom
fix/exact-output-underfill
Open

fix: revert on exact-output partial fills in V4Router#564
ccashwell wants to merge 2 commits into
mainfrom
fix/exact-output-underfill

Conversation

@ccashwell

@ccashwell ccashwell commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

A v4 exact-output swap can partial-fill: _swap hard-codes sqrtPriceLimit to 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 requested amountOut. 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): priceX36 was computed from the requested amountOut over the actual amountIn, overstating the execution price, so on a partial fill a swap could pass minHopPriceX36 at a realized price below the caller's bound.

Fix

Make exact output all-or-nothing in V4Router:

  • _swap now returns the full BalanceDelta; two pure helpers (_swapInput / _swapOutput) extract the realized input/output, preserving the prior reciprocal extraction exactly.
  • _swapExactOutputSingle and _swapExactOutput (per hop) assert the realized output covers the requested amount and revert a new V4ExactOutputUnfilled(amountOutRequested, amountOutReceived) on a shortfall.
  • The per-hop price guard now prices the realized output.

Behavior:

  • Exact-input is unchanged (it already checked delivery via amountOutMinimum and priced on realized output).
  • Full fills are unaffected — a fully-filled exact-output swap delivers exactly amountOut, so the check is a no-op for them (verified: all existing exact-output router tests pass unchanged).
  • The check is realizedOut < amountOut (revert on shortfall), not strict !=, so over-delivery is allowed — it can only arise via hook pools, is harmless (amountInMaximum still 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/afterSwapDelta can 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:

  1. Is revert-on-shortfall the desired canonical behavior for exact output, or should hook pools be exempt / opt-out?
  2. realizedOut < amountOut (allow over-delivery) vs strict realizedOut != amountOut (literal "exact"). This PR uses the former, but I'm not 100% on whether this is the right call.

Context

Surfaced while integrating V4Router in a margin-trading periphery (#563), where exact-output is used to size leverage opens. That PR guards the gap at the application layer (an ASSERT_FILL / IncompleteFill check 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.

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
ccashwell force-pushed the fix/exact-output-underfill branch from 580a8c5 to 4c81aa3 Compare June 17, 2026 20:33
@ccashwell
ccashwell requested review from gretzke, hensha256 and zhongeric and removed request for gretzke June 17, 2026 20:46
… 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
hensha256 marked this pull request as ready for review July 8, 2026 14:54
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