Skip to content

fix(split routing): topological order for cross-depth hops #239

Open
tamaralipows wants to merge 2 commits into
split-routing/tnl/validate-refactorfrom
split-routing/tnl/fix-split-different-depths
Open

fix(split routing): topological order for cross-depth hops #239
tamaralipows wants to merge 2 commits into
split-routing/tnl/validate-refactorfrom
split-routing/tnl/fix-split-different-depths

Conversation

@tamaralipows
Copy link
Copy Markdown
Contributor

@tamaralipows tamaralipows commented Jun 4, 2026

Problem, summarized:

The original two paths:

WETH ───▶ USDC ─── pool_a ──▶ DAI                    (shorter path)
WETH ───▶ USDT ────────▶ USDC ── pool_a ──▶ DAI      (longer path)

The merged paths:

WETH ──┬────────────▶ USDC ─── pool_a ──▶ DAI
       │                ▲
       └────▶ USDT ─────┘
                                       

Our merging algo was emitting the USDC ─ pool_a ─▶ DAI before emitting the USDT ─▶ USDC swap. This would cause unintended consequences on our router side, since only the amount for WETH ─▶ USDC would be inputted into the final USDC ─▶ DAI swap, ignoring the USDT ─▶ USDC amount.

Related addition to the Tycho docs: propeller-heads/tycho#1068

Solution

Replace naive BFS in build_split_route with Kahn's topological sort.

Summary of Kahn's topological sort:

  1. Count incoming edges for every node (the "in-degree")
  2. Enqueue all nodes with in-degree 0 — they have no dependencies
  3. Loop: pop a node from the queue, "emit" it, and decrement the in-degree of each of its neighbors. Whenever a neighbor's in-degree reaches 0, enqueue it
  4. Done when the queue is empty. If any nodes were never enqueued, the graph has a dependency cycle (A → B → A → C) — distinct from a valid trade cycle (A → B → A) where the trade starts and ends at the same token

Consequence

The USDC ─── pool_a ──▶ DAI will now be one swap meaning we should only count gas once. The swap will be emitted only after the inflow swaps are emitted. Our router is happy.

Important Note: Unsupported edge case

The following case is still not supported, because Pool A and Pool B cause a deadlock from being swapped in opposite orders in different branches:

WETH ─┬─ USDC ─ (Pool A) ─ DAI ─ PEPE ─ (Pool B) ─ UNI ─ WBTC
      └─ PEPE ─ (Pool B) ─ UNI ─ USDC ─ (Pool A) ─ DAI ─ WBTC

This is a pretty rare case and any solutions I found resulted in a massive refactor. For now, I've added a test that ensures this results in an error. Let me know if you agree or if you think this is worth spending time to support.

@tamaralipows tamaralipows force-pushed the split-routing/tnl/fix-split-different-depths branch from 06fae40 to 4216717 Compare June 4, 2026 16:54
@tamaralipows tamaralipows changed the title fix(split routing): topological swap ordering for cross-depth convergence @tamaralipows @claude fix(split routing): topological order for cross-depth hops Jun 4, 2026
@tamaralipows tamaralipows changed the title @tamaralipows @claude fix(split routing): topological order for cross-depth hops fix(split routing): topological order for cross-depth hops Jun 4, 2026
@tamaralipows tamaralipows force-pushed the split-routing/tnl/fix-split-different-depths branch 6 times, most recently from f56881e to e76c4d4 Compare June 4, 2026 20:40
/// ```
///
/// The DAI→PEPE split between Pool B and Pool C must wait until all DAI
/// has been produced (from both paths through the merged Pool A swap).
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring getting too long. Considering removing this example.

@tamaralipows tamaralipows force-pushed the split-routing/tnl/fix-split-different-depths branch from e76c4d4 to aa3d5d6 Compare June 4, 2026 20:44
/// The DAI→PEPE split between Pool B and Pool C must wait until all DAI
/// has been produced (from both paths through the merged Pool A swap).
pub(crate) fn build_split_route(
paths: &[PathAllocation],
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's okay that we are already taking the deduplicated (after merged_shared_hops) paths here, because in-degree isn't tracking how many times Pool A appears - it's tracking how many distinct swaps produce a given token. We still have this information with the given data.

@tamaralipows tamaralipows self-assigned this Jun 4, 2026
Replace naive BFS in build_split_route with Kahn's topological sort.
The BFS visited tokens in discovery order, which broke when paths of
different lengths converged on the same intermediate token — the shorter
path caused the token to be visited before the longer path's inflows
arrived. Since merge_shared_hops collapses all appearances of a shared
pool hop into a single combined swap (one hop on the router, not one
per path), all inflows to a token must be complete before its outgoing
swap is emitted. This also saves gas: a single hop on the pool with
the combined input is cheaper than multiple hops on the same pool. The
topological sort guarantees this by tracking the in-degree of each
token and only processing it once every upstream swap that produces it
has been emitted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tamaralipows tamaralipows force-pushed the split-routing/tnl/fix-split-different-depths branch from aa3d5d6 to 50b86d7 Compare June 4, 2026 22:38
@tamaralipows tamaralipows changed the base branch from main to split-routing/tnl/validate-refactor June 4, 2026 22:48
When two paths use the same pools in opposite order (e.g. path 1 uses
Pool A then Pool B, path 2 uses Pool B then Pool A), merge_shared_hops
collapses them into single swaps, creating a cycle in the token
dependency graph. The topological sort then deadlocks — some tokens
never reach in-degree zero. Detect this after the sort and return an
error instead of silently producing a partial route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tamaralipows tamaralipows force-pushed the split-routing/tnl/fix-split-different-depths branch from 8ada7d8 to de4682c Compare June 4, 2026 23:33
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