Skip to content

fix(router-sdk): encodeMixedRouteToPath resolves the wrong pool token at a native/wrapped boundary - #716

Open
gomesalexandre wants to merge 1 commit into
Uniswap:mainfrom
gomesalexandre:fix_encode_mixed_route_fallthrough
Open

fix(router-sdk): encodeMixedRouteToPath resolves the wrong pool token at a native/wrapped boundary#716
gomesalexandre wants to merge 1 commit into
Uniswap:mainfrom
gomesalexandre:fix_encode_mixed_route_fallthrough

Conversation

@gomesalexandre

Copy link
Copy Markdown

What

encodeMixedRouteToPath re-derives each hop's output token via:

const currencyOut = currencyIn.equals(pool.token0) ? pool.token1 : pool.token0

This silently falls through to pool.token0 whenever currencyIn doesn't exactly match pool.token0 (by reference/.equals()) — which is exactly what happens at a native/wrapped currency boundary, where MixedRouteSDK's constructor resolves the correct side via a wrapped-equality fallback (inputToken.wrapped.equals(pool.token0) etc.), not plain .equals().

Both branches (the v4 branch and the legacy non-v4 fallback) had this same pattern. Both now read each hop's currency directly from route.path, which the constructor already resolves correctly — this mirrors the fix already applied to MixedRouteSDK.midPrice and getOutputOfPools in #706 (ROUTE-886), merged two days before I found this. That PR's own comment states the invariant this fix relies on: route.path[i + 1] is always pool i's own currency object.

Why the existing 20 fixtures never caught it

Every non-WETH token address used in the existing test fixtures (0x...0001, 0x...0002, 0x...0003) happens to sort below WETH's real address (0xC02aaa39...). Since there are only two tokens per pool, and the fallthrough always returns pool.token0, whenever the "other" token happens to be token0 (i.e. sorts below WETH), the fallthrough is coincidentally correct. It's only wrong when the non-WETH token sorts above WETH's address — none of the existing fixtures do.

Repro

route: USDC -[V4 pool]-> ETH -[V3 pool, WETH<->USDT]-> USDT
route.path (constructor-resolved, correct): [USDC, ETH, USDT]
route.pathOutput: USDT

BEFORE fix: encoded path terminates at 0xc02aaa...756cc2 (WETH's address)
AFTER  fix: encoded path terminates at 0xdac17f...31ec7  (USDT's address, correct)

Confirmed via git stash that this is a genuine red-before/green-after — the encoded path silently pointed at the wrong terminal token (a WETH/WETH self-loop in the legacy-branch case tested), not a display nit.

Consumers

Within this monorepo, encodeMixedRouteToPath is used directly by:

  • universal-router-sdk/src/entities/actions/uniswap.ts — building the actual swap command's path argument
  • router-sdk/src/swapRouter.ts — the legacy swap-router path builder

So this isn't just a latent/unused public export — it's wired into real swap-calldata construction inside the same monorepo. I did not check external consumers (e.g. smart-order-router, a separate repo) for how they construct routes that reach this encoder, so I can't speak to how often a real multihop route crosses a native/wrapped boundary at a non-default token ordering in production. What's proven here is the code-level defect and its consequence when it fires: the built swap path targets the wrong pool/token, which would either revert (no such pool) or, worse, route through an unintended pool.

Testing

  • Added two regression tests (one per branch) using a token address that sorts above WETH's, specifically to break the coincidental-correctness case. Both fail on unfixed code with the exact predicted symptom (wrong terminal address / WETH self-loop) and pass after the fix — confirmed via git stash.
  • All 20 pre-existing fixtures remain byte-identical (regenerated and diffed, zero drift).
  • Full package suite: 374/374 passing (bun test).
  • tsc --noEmit clean.
  • bun run lint currently fails on unmodified main too (ESLint: ... all files matching the glob are ignored) — confirmed via git stash that this is a pre-existing repo/tooling issue, not something this PR introduced or needs to fix.

Codex review

Ran Codex adversarially (synchronous, own tracked PID). It did not return a verdict within ~7 minutes — killed the tracked PID cleanly (confirmed no lingering process) and did a thorough self-review instead, covering: semantic correctness of both branches (verified via the real repro above), whether route.path can ever have an unexpected length relative to route.pools (no — the constructor always pushes exactly one entry per pool after the initial pathInput, so route.path.length === route.pools.length + 1 by construction, and route.path[i+1] is always in bounds), whether the new tests are non-vacuous (confirmed via stash-based red-before/green-after), and whether any existing behavior regressed (confirmed via the full 374-test suite and byte-identical existing fixtures).

Draft opened and flipped to ready per my usual workflow — real command output above, not descriptions.

encodeMixedRouteToPath re-derived each hop's output token via
currencyIn.equals(pool.token0) ? pool.token1 : pool.token0, which
silently falls through to pool.token0 whenever neither pool token
matches currencyIn by exact reference -- exactly what happens at a
native/wrapped currency boundary, where MixedRouteSDK's constructor
resolves the correct side via wrapped-equality rather than .equals().

Both branches (v4 and the legacy non-v4 fallback) now read each hop's
currency directly from route.path, which the constructor already
resolves correctly, mirroring the fix already applied to midPrice and
getOutputOfPools in Uniswap#706 (ROUTE-886).

Reproduced the divergence with a real route (USDC -[V4]-> ETH -[V3,
WETH<->USDT]-> USDT): unfixed code encoded a path terminating at
WETH's address instead of USDT's -- a genuine self-loop / wrong
terminal token, not just a display nit. The existing test fixtures
never caught this because every non-weth token address in them
happens to sort below WETH's, which makes the fallthrough
coincidentally correct; the new tests use a token that sorts above
WETH's address specifically to break that coincidence.

All 20 pre-existing fixtures remain byte-identical. Confirmed real
red-before/green-after via git stash on both the source fix and the
new regression tests.
@gomesalexandre
gomesalexandre marked this pull request as ready for review September 1, 2026 21:32
@gomesalexandre
gomesalexandre requested a review from a team as a code owner September 1, 2026 21:32
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.

1 participant