fix(router-sdk): suppress aggregated slippage check in swap-and-add (#514)#584
Open
Kropiunig wants to merge 1 commit into
Open
fix(router-sdk): suppress aggregated slippage check in swap-and-add (#514)#584Kropiunig wants to merge 1 commit into
Kropiunig wants to merge 1 commit into
Conversation
…niswap#514) `SwapRouter.encodeSwaps` previously enabled `performAggregatedSlippageCheck` for any EXACT_INPUT trade with more than two routes, which sets each individual swap's `amountOutMinimum` to zero and shifts enforcement onto a trailing `sweepToken` / `unwrapWETH9` call that pins the router's balance against the aggregate floor. `swapAndAddCallParameters` has no such trailing aggregate guard - the trailing sweeps it emits use `ZERO` because their job is to flush leftovers after the LP mint, not to bound swap output. The only remaining downstream guard is `Position#mintAmountsWithSlippage`'s `amount0Min` / `amount1Min`, and `ApproveAndCall.encodeAddLiquidity` then takes the smaller of that boundary-derived value and `minimalPosition`. For positions with wide or near-boundary price ranges this can land 30%+ below the user-requested slippage floor (see issue Uniswap#514 for an on-chain example where a 3% nominal slippage was effectively 29%). The fix is to never aggregate in the swap-and-add path, so individual swaps keep their own `amountOutMinimum`s. This loses the aggregated-check gas optimization for multi-route swap-and-add, but it restores the slippage guarantee the SDK promises its callers, and matches the protection profile of `swapCallParameters` + a trailing sweep. Tests cover four scenarios by decoding the multicall and inspecting every inner swap's `amountOutMinimum`: - 3-route EXACT_INPUT swap-and-add preserves per-swap floors and their sum equals the SDK's documented aggregate minimum; - 3-route EXACT_INPUT regular `swapCallParameters` still aggregates (sanity check that the gas optimization is preserved where it is safe); - 2-route swap-and-add is unchanged because the heuristic never fired; - aggregate floor across mixed-protocol routes (which section-split) is never lower than the SDK-reported minimum. Fixes Uniswap#514.
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.
Fixes #514.
swapAndAddCallParameterswas silently degrading EXACT_INPUT slippage protection on multi-route swaps because it sharedperformAggregatedSlippageCheckwith the regular swap path, even though it lacks the trailing sweep that pins the router balance against the aggregate floor.Root cause
SwapRouter.encodeSwapstoggles aggregation on whenever an EXACT_INPUT trade has more than two routes:When the flag is set, every individual swap call carries
amountOutMinimum = 0and enforcement is supposed to move downstream:swapCallParameters, the trailingunwrapWETH9(minimumAmountOut, ...)/sweepToken(tokenOut, minimumAmountOut, ...)pins the router's balance against the aggregate floor. The optimization is safe.swapAndAddCallParameters, the trailing sweeps it emits useZERObecause their job is to flush leftovers after the LPmint. The only remaining downstream guard isApproveAndCall.encodeAddLiquidity'samount0Min/amount1Min, which is the smaller ofPosition#mintAmountsWithSlippage(...)and aminimalPositionderived from the swap'sminimumAmountOut.mintAmountsWithSlippageis allowed to lower bounds far below the swap-derived minimum when the position price range is wide or near a boundary - that is by design for concentrated-liquidity ratio drift, but it isn't a swap-output guarantee. AggregatingamountOutMinimums away leaves a window where the swap output can fall far below the user's tolerance and the LP mint will still succeed.Issue #514 documents an on-chain transaction where a 3% nominal slippage degraded to ~29% effective slippage on a USDC -> cbBTC swap-and-add.
Approach
The smallest correct fix is to suppress aggregation when
isSwapAndAddis true.encodeSwapsalready receivesisSwapAndAddas its third argument, so a single conjunct restores the original per-swap slippage guarantee. Each swap reverts on its own floor, and the sum of those floors is exactly the SDK's documented aggregate minimum.Alternatives considered
sweepToken(tokenOut, minimumAmountOut, router)between swaps andmint. Preserves the gas optimization, butPayments.sweepTokenrequires a concrete recipient and there is noADDRESS_THIS-style sentinel for the V3 SwapRouter02 ABI (those constants belong to Universal Router). Threading a router address intoswapAndAddCallParametersis a wider, leakier change.max()instead ofmin()inApproveAndCall.encodeAddLiquidity. Wrong direction: the existingmin()is deliberate so that mints don't revert when ratio drift trims one side legitimately. The bug isn't with themin(); it's that the swap-level minimum was thrown away upstream.mintAmountsWithSlippageitself. Same problem - that function is correct for its own contract (LP boundary math) and is used by many other call sites.(1) reads like the right long-term fix if Uniswap Labs wants to keep the aggregated-check gas optimization for swap-and-add. Happy to follow up with that in a separate PR if preferred; for this PR I optimized for the smallest surgical change that closes the security hole without touching the LP math.
Test coverage
Tests live in
sdks/router-sdk/src/swapRouter.test.tsunder#swapAndAddCallParameters aggregated-slippage regression (issue #514). They decode the outer multicall (handling bothmulticall(bytes[])and the deadline-bearingmulticall(uint256,bytes[])) and inspect every recognised inner swap'samountOutMinimum:preserves per-swap amountOutMinimum on 3-route EXACT_INPUT swap-and-addSum(trade.minimumAmountOut(slippage)).regular #swapCallParameters still aggregates the slippage check (3+ EXACT_INPUT trades)two-route swap-and-add is unchanged (numberOfTrades > 2 is false)aggregate swap-and-add floor is never lower than the SDK-reported minimumMixedRouteTradesection-splitting.I confirmed the first and fourth tests fail on the pre-fix code (
expected: true / received: falseon the aggregate-floor assertion) and pass on the fixed code.Verification
Full monorepo
bun run g:lintis also clean.bun run g:testonly fails onuniswapx-integration, which is a pre-existing hardhat config issue (HH8: Invalid value ... for HardhatConfig.networks.hardhat) unrelated to this PR; the failure reproduces onupstream/mainwithout the patch applied.No public API surface is touched, so this is a
patchbump for@uniswap/router-sdk(and a downstreampatchfor@uniswap/universal-router-sdkviaupdateInternalDependencies).Are there any breaking changes?
No. The output calldata changes only for multi-route (
numberOfTrades > 2) EXACT_INPUT swap-and-add - in those cases the encodedamountOutMinimumper swap is now the per-swap floor instead of0. That is the bug fix.