Skip to content

fix(router-sdk): suppress aggregated slippage check in swap-and-add (#514)#584

Open
Kropiunig wants to merge 1 commit into
Uniswap:mainfrom
Kropiunig:fix/router-sdk-swap-and-add-aggregated-slippage
Open

fix(router-sdk): suppress aggregated slippage check in swap-and-add (#514)#584
Kropiunig wants to merge 1 commit into
Uniswap:mainfrom
Kropiunig:fix/router-sdk-swap-and-add-aggregated-slippage

Conversation

@Kropiunig
Copy link
Copy Markdown

Fixes #514. swapAndAddCallParameters was silently degrading EXACT_INPUT slippage protection on multi-route swaps because it shared performAggregatedSlippageCheck with the regular swap path, even though it lacks the trailing sweep that pins the router balance against the aggregate floor.

Root cause

SwapRouter.encodeSwaps toggles aggregation on whenever an EXACT_INPUT trade has more than two routes:

const performAggregatedSlippageCheck =
  sampleTrade.tradeType === TradeType.EXACT_INPUT && numberOfTrades > 2

When the flag is set, every individual swap call carries amountOutMinimum = 0 and enforcement is supposed to move downstream:

  • In swapCallParameters, the trailing unwrapWETH9(minimumAmountOut, ...) / sweepToken(tokenOut, minimumAmountOut, ...) pins the router's balance against the aggregate floor. The optimization is safe.
  • In swapAndAddCallParameters, the trailing sweeps it emits use ZERO because their job is to flush leftovers after the LP mint. The only remaining downstream guard is ApproveAndCall.encodeAddLiquidity's amount0Min / amount1Min, which is the smaller of Position#mintAmountsWithSlippage(...) and a minimalPosition derived from the swap's minimumAmountOut.

mintAmountsWithSlippage is 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. Aggregating amountOutMinimums 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 isSwapAndAdd is true. encodeSwaps already receives isSwapAndAdd as 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.

const performAggregatedSlippageCheck =
  sampleTrade.tradeType === TradeType.EXACT_INPUT && numberOfTrades > 2 && !isSwapAndAdd

Alternatives considered

  1. Inject an extra sweepToken(tokenOut, minimumAmountOut, router) between swaps and mint. Preserves the gas optimization, but Payments.sweepToken requires a concrete recipient and there is no ADDRESS_THIS-style sentinel for the V3 SwapRouter02 ABI (those constants belong to Universal Router). Threading a router address into swapAndAddCallParameters is a wider, leakier change.
  2. Use max() instead of min() in ApproveAndCall.encodeAddLiquidity. Wrong direction: the existing min() is deliberate so that mints don't revert when ratio drift trims one side legitimately. The bug isn't with the min(); it's that the swap-level minimum was thrown away upstream.
  3. Tighten mintAmountsWithSlippage itself. 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.ts under #swapAndAddCallParameters aggregated-slippage regression (issue #514). They decode the outer multicall (handling both multicall(bytes[]) and the deadline-bearing multicall(uint256,bytes[])) and inspect every recognised inner swap's amountOutMinimum:

Test Asserts
preserves per-swap amountOutMinimum on 3-route EXACT_INPUT swap-and-add every inner swap has a non-zero floor; their sum equals Sum(trade.minimumAmountOut(slippage)).
regular #swapCallParameters still aggregates the slippage check (3+ EXACT_INPUT trades) control case - the gas optimization is preserved where it is safe.
two-route swap-and-add is unchanged (numberOfTrades > 2 is false) the heuristic never fired here pre-fix, so 2-route encodings must be byte-identical.
aggregate swap-and-add floor is never lower than the SDK-reported minimum property check that also covers MixedRouteTrade section-splitting.

I confirmed the first and fourth tests fail on the pre-fix code (expected: true / received: false on the aggregate-floor assertion) and pass on the fixed code.

Verification

cd sdks/router-sdk
bun test         # 364 pass (was 360 baseline + 4 new)
bun run build    # tsc -p cjs/esm/types all green
bun run lint     # clean

Full monorepo bun run g:lint is also clean. bun run g:test only fails on uniswapx-integration, which is a pre-existing hardhat config issue (HH8: Invalid value ... for HardhatConfig.networks.hardhat) unrelated to this PR; the failure reproduces on upstream/main without the patch applied.

No public API surface is touched, so this is a patch bump for @uniswap/router-sdk (and a downstream patch for @uniswap/universal-router-sdk via updateInternalDependencies).

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 encoded amountOutMinimum per swap is now the per-swap floor instead of 0. That is the bug fix.

…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.
@Kropiunig Kropiunig requested a review from a team as a code owner May 16, 2026 17:46
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.

[Security] Slippage Protection Significantly Weakened in swapAndAdd for Certain Price Ranges

1 participant