fix(lens): SafeCast the swap delta conversions in V4Quoter - #588
Open
gretzke wants to merge 3 commits into
Open
Conversation
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>
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.
Cantina #910, plus two related sites the finding did not cover.
What was wrong
V4Quoterread the unspecified side of a swap'sBalanceDeltawith a bare truncatinguint128(...)at four places. Only the specified side is guaranteed to carry the expected sign. The unspecified side is whatever remains afterHooks.afterSwapapplies the hook's returned delta:A hook holding
AFTER_SWAP_FLAGandAFTER_SWAP_RETURNS_DELTA_FLAGcan return more than the pool produced and drive that side past zero.Hooks.isValidHookAddressaccepts the combination, so this is a supported hook, not a misbehaving one.The only guard that existed cannot see it.
BaseV4Quoter._swapcompares the specified side against the requested amount:So the wrapped value went straight to
revertQuoteand 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
V4Routeralready 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,
_quoteExactInputand_quoteExactInputSingle, take theint128overload oftoUint128, matching_swapOutput:Input sides,
_quoteExactOutputand_quoteExactOutputSingle, widen before negating, matching_swapInput:The widening is not cosmetic. Negating an
int128first would panic ontype(int128).minbefore 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
amountInof 0 instead of reverting, so a route the router can execute stays quotable.Two related sites, not in the finding
_quoteExactInputand_quoteExactInputSinglebuilt the swap amount as-int256(int128(amountIn))from auint128. Abovetype(int128).maxthat reinterprets as a negativeint128, and the outer negation flips the sign. A positiveamountSpecifiedmeans exact output, so an exact-input quote silently simulated an exact-output swap. Reachable through the publicuint128interface. The router widens throughuint256at 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:
_swapOutputrejects that state, and_getFullCreditreverts 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
SafeCastOverflowbubbles out of the simulation andparseQuoteAmountwraps it inUnexpectedRevertBytes, the same outer errorNotEnoughLiquidityalready produces. No interface change.Known divergence, left alone
In multi-hop exact output, a fully hook-funded hop makes
V4Router._swapExactOutputbreak out of its loop, while the quoter propagates the zero into the nextswapand hitsSwapAmountCannotBeZero. That is a revert rather than a wrong number, so it is out of scope here.test_quoteExactOutput_fullyFundedHop_revertsSwapAmountCannotBeZerorecords 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, theuint128toint128reinterpretation, thetype(int128).minboundary, the zero-input hook-funded case, and the divergence above.Reverting
src/lens/V4Quoter.solalone 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 thetype(int128).mincase 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 topath[0]makes its converted value the returnedamountIndirectly, with nothing downstream to mask it.Snapshots
snapshots/QuoterTest.jsonregenerated 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
V4Quoteris immutable. This needs a redeploy plus registry and consumer updates to take effect.