Skip to content

fix(lens): SafeCast the swap delta conversions in V4Quoter - #588

Open
gretzke wants to merge 3 commits into
mainfrom
fix/quoter-unchecked-delta-casts
Open

fix(lens): SafeCast the swap delta conversions in V4Quoter#588
gretzke wants to merge 3 commits into
mainfrom
fix/quoter-unchecked-delta-casts

Conversation

@gretzke

@gretzke gretzke commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Cantina #910, plus two related sites the finding did not cover.

What was wrong

V4Quoter read the unspecified side of a swap's BalanceDelta with a bare truncating uint128(...) at four places. Only the specified side is guaranteed to carry the expected sign. The unspecified side is whatever remains after Hooks.afterSwap applies the hook's returned delta:

swapDelta = swapDelta - hookDelta;

A hook holding AFTER_SWAP_FLAG and AFTER_SWAP_RETURNS_DELTA_FLAG can return more than the pool produced and drive that side past zero. Hooks.isValidHookAddress accepts the combination, so this is a supported hook, not a misbehaving one.

The only guard that existed cannot see it. BaseV4Quoter._swap compares the specified side against the requested amount:

int128 amountSpecifiedActual = (zeroForOne == (amountSpecified < 0)) ? swapDelta.amount0() : swapDelta.amount1();
if (amountSpecifiedActual != amountSpecified) revert NotEnoughLiquidity(poolKey.toId());

So the wrapped value went straight to revertQuote and back to the caller. The reporter reproduced it on a mainnet fork against the deployed quoter and PoolManager: an honest quote of 996006981039903216 became 340282366920938463463374607431768211455.

No funds move, since the quoter always reverts its own simulation. The damage is to routing. A consumer that ranks candidate pools by raw quoter output picks the attacker's pool over every honest one on that pair, and the resulting swap is built against a route that pays nothing.

The fix

V4Router already converts these same two quantities correctly, so the quoter now uses those exact forms rather than inventing new ones. A quote that accepts pool states the real swap rejects is its own bug class.

Output sides, _quoteExactInput and _quoteExactInputSingle, take the int128 overload of toUint128, matching _swapOutput:

amountIn = zeroForOne ? swapDelta.amount1().toUint128() : swapDelta.amount0().toUint128();

Input sides, _quoteExactOutput and _quoteExactOutputSingle, widen before negating, matching _swapInput:

uint256 amountIn = params.zeroForOne
    ? uint256(-int256(swapDelta.amount0())).toUint128()
    : uint256(-int256(swapDelta.amount1())).toUint128();

The widening is not cosmetic. Negating an int128 first would panic on type(int128).min before SafeCast ran, and that is a legitimate magnitude rather than a corrupted one. The widened form yields 2^127. There is a test for it.

Note that the input sites cannot use the plain .toUint128() form. They negate a legitimately negative delta, so that form would reject every ordinary exact-output quote.

Matching the router also preserves the hook-funded input case from #584. An input delta of exactly zero still quotes as an amountIn of 0 instead of reverting, so a route the router can execute stays quotable.

Two related sites, not in the finding

_quoteExactInput and _quoteExactInputSingle built the swap amount as -int256(int128(amountIn)) from a uint128. Above type(int128).max that reinterprets as a negative int128, and the outer negation flips the sign. A positive amountSpecified means exact output, so an exact-input quote silently simulated an exact-output swap. Reachable through the public uint128 interface. The router widens through uint256 at both of its equivalent sites, and the quoter now does the same.

Why revert rather than clamp to zero

A negative output delta does not mean the pool pays nothing. It means the caller owes the output currency as well as the input currency. Clamping would report a route the router cannot execute: _swapOutput rejects that state, and _getFullCredit reverts on it during settlement. Reverting keeps the quote and the swap in agreement about which pools are usable, and a route planner that catches quote failures discards the candidate anyway.

This does not give a hook new leverage. A hook is part of the PoolKey, so it cannot be attached to an existing honest pool, a route has to name the hooked pool explicitly, and a hook that wants a route unquotable can already just revert in its callback.

Behavior on rejection

SafeCastOverflow bubbles out of the simulation and parseQuoteAmount wraps it in UnexpectedRevertBytes, the same outer error NotEnoughLiquidity already produces. No interface change.

Known divergence, left alone

In multi-hop exact output, a fully hook-funded hop makes V4Router._swapExactOutput break out of its loop, while the quoter propagates the zero into the next swap and hits SwapAmountCannotBeZero. That is a revert rather than a wrong number, so it is out of scope here. test_quoteExactOutput_fullyFundedHop_revertsSwapAmountCannotBeZero records the current behavior so a future change to it is visible.

Testing

10 new tests in test/lens/V4QuoterHookDelta.t.sol, covering all four conversion sites in both directions, the uint128 to int128 reinterpretation, the type(int128).min boundary, the zero-input hook-funded case, and the divergence above.

Reverting src/lens/V4Quoter.sol alone fails 8 of the 10. Four fail with "next call did not revert as expected", which is the wrapped value being returned as a valid quote, and the type(int128).min case fails with an arithmetic overflow panic. Two pass either way by design: the zero-input case is a no-regression check, and the divergence test documents behavior this PR does not change.

Full suite is green, 779 passed and 2 fork tests skipped.

One test needed a second pass worth flagging for reviewers. The multi-hop exact-output test originally placed the hooked pool at path[1]. The backward loop processes that hop first, so the wrapped value was consumed by the next swap and detonated inside core with coincidentally the same error the test asserted, meaning it passed against unfixed source. Moving the hooked pool to path[0] makes its converted value the returned amountIn directly, with nothing downstream to mask it.

Snapshots

snapshots/QuoterTest.json regenerated with Foundry v1.3.6, the version CI pins. Local toolchains on other versions produce different Quoter numbers, which caused a false drift report during #584, so these were deliberately not generated with a newer local forge.

Deployment

The deployed V4Quoter is immutable. This needs a redeploy plus registry and consumer updates to take effect.

gretzke and others added 3 commits August 12, 2026 01:58
V4Quoter read the unspecified side of a swap's BalanceDelta with a bare
truncating uint128(...). Only the specified side is guaranteed to have the
expected sign. The unspecified side is whatever remains after Hooks.afterSwap
applies the hook's returned delta, and a hook holding AFTER_SWAP_FLAG plus
AFTER_SWAP_RETURNS_DELTA_FLAG can drive it past zero.

The only existing guard, BaseV4Quoter._swap's NotEnoughLiquidity check, compares
the specified side against the requested amount, so it cannot see this. The cast
wrapped and the quoter returned roughly 2^128 as a successful quote with no
revert, for a pool that in reality pays out nothing. A consumer that ranks
candidate pools by raw quoter output picks that pool over every honest one.

V4Router already handles the same two quantities correctly, so the quoter now
uses those exact forms. The output sides at _quoteExactInput and
_quoteExactInputSingle take the int128 overload of toUint128, matching
_swapOutput. The input sides at _quoteExactOutput and _quoteExactOutputSingle
widen before negating, uint256(-int256(x)).toUint128(), matching _swapInput.

The widening matters on its own. Negating an int128 first would panic on
type(int128).min before SafeCast ever ran, and that input is a legitimate
magnitude, not a corrupted one. The widened form yields 2^127.

Keeping the router's forms also preserves the hook-funded input case added in
bbd4346: an input delta of exactly zero still quotes as an amountIn of 0
rather than reverting, so a route the router can execute stays quotable.

Two related sites are fixed alongside. _quoteExactInput and
_quoteExactInputSingle built the swap amount as -int256(int128(amountIn)) from a
uint128. Above type(int128).max that reinterprets as negative, and the outer
negation flips the sign, so an exact-input quote silently simulated an
exact-output swap. Both now widen through uint256 as the router does.

A hook that corrupts the delta makes its own pool unquotable rather than
returning a clamped zero. Clamping would be wrong: a negative output delta means
the caller owes the output currency, not that the pool pays nothing, and the
router rejects that state too. Reverting keeps the quote and the swap in
agreement about which pools are usable.

Behavior on rejection is the existing shape. SafeCastOverflow bubbles out of the
simulation and parseQuoteAmount wraps it in UnexpectedRevertBytes, the same
outer error NotEnoughLiquidity already produces. No interface change.

One divergence from the router is left in place. In multi-hop exact output, a
fully hook-funded hop makes the router break out of its loop, while the quoter
propagates the zero into the next swap and hits SwapAmountCannotBeZero. That is
a revert rather than a wrong number. A test records the current behavior so a
future change to it is visible.

Reverting src/lens/V4Quoter.sol fails 8 of the 10 new tests. Four fail with "did
not revert as expected", which is the wrapped value being returned as a valid
quote, and the type(int128).min case fails with an arithmetic overflow panic.

Snapshots regenerated with Foundry v1.3.6 to match the version CI pins.
…e input

V4Router._swapExactOutput breaks out of its reverse loop once a hop's input
delta reaches zero, since a hook that fully funds the input leaves the
upstream pools with nothing to produce. V4Quoter._quoteExactOutput never
gained the mirror guard, so it propagated the zero into the preceding pool
and called swap with an amountSpecified of zero, which PoolManager rejects.
The QuoteSwap payload was then replaced by SwapAmountCannotBeZero on the way
out, surfacing as UnexpectedRevertBytes instead of a zero-input quote.

Routes the router executes for free were therefore unquotable, so
quote-dependent integrations excluded valid subsidy paths.

test_quoteExactOutput_fullyFundedHop_revertsSwapAmountCannotBeZero asserted
the broken behavior, so it passed against the bug. It is replaced by
test_quoteExactOutput_fullyFundedHop_quotesZeroInput, which pins the quote to
zero and matches the router's
test_exactOutput_multiHop_hookFundsFinalHop_skipsUpstreamHops.

Costs 29 gas per exact-output hop.

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