Skip to content

feat: add multi-DEX swap router with optimal route finding#47

Open
HERO570 wants to merge 1 commit intoStellar-Tools:mainfrom
HERO570:feat/multi-dex-swap-router
Open

feat: add multi-DEX swap router with optimal route finding#47
HERO570 wants to merge 1 commit intoStellar-Tools:mainfrom
HERO570:feat/multi-dex-swap-router

Conversation

@HERO570
Copy link
Copy Markdown

@HERO570 HERO570 commented Mar 30, 2026

Summary

  • Adds a swap router that finds the best multi-hop trade routes across all major Stellar liquidity sources (Soroswap, Phoenix DEX, Stellar SDEX)
  • Supports mainnet and testnet with configurable network settings
  • Backward-compatible API — existing swap() calls work unchanged, new strategy: "best-route" opt-in for routed swaps
  • Includes route preview via getSwapRoute() and LangChain tool support via routed_swap action

How it works

// Routed swap — finds best path across all DEXes
await agent.swap({
  tokenIn: "native",
  tokenOut: "USDC:GA5ZSE...",
  amount: "1000000000",
  slippage: 0.5,
  strategy: "best-route",
  maxHops: 3
});

// Old API still works
await agent.swap({ to, buyA, out, inMax });

Architecture: Adapter pattern with graph-based pathfinding. Each DEX implements a DexAdapter interface. A PoolRegistry caches pool discovery with live reserve refresh at swap time. A SwapRouter builds a token graph and runs BFS to find the optimal multi-hop route (up to 3 hops).

New files

File Purpose
router/types.ts Core interfaces (DexAdapter, Pool, Quote, Route)
router/config.ts Network configs (mainnet/testnet factory addresses)
router/graph.ts Token graph with AMM math and pathfinding
router/registry.ts Pool registry with time-based caching
router/router.ts SwapRouter orchestrator
router/adapters/soroswap.ts Soroswap DEX adapter
router/adapters/phoenix.ts Phoenix DEX adapter
router/adapters/sdex.ts SDEX adapter (Horizon order book + pathPayment)

Test plan

  • 67 tests passing across 13 test files
  • Unit tests for graph pathfinding (direct, multi-hop, no route, cycle prevention, maxHops)
  • Unit tests for pool registry (caching, refresh, adapter failure resilience)
  • Unit tests for each DEX adapter (interface compliance, quote math)
  • Unit tests for SwapRouter (cross-DEX routing, rate comparison)
  • Integration test for backward-compatible swap API
  • Manual testing on testnet with real pools
  • Manual testing on mainnet with real liquidity

Summary by cubic

Adds a multi-DEX swap router that finds the best multi-hop route across Soroswap, Phoenix, and Stellar SDEX, with mainnet/testnet support and a backward-compatible swap() API. Opt-in routed swaps use strategy: "best-route" and include route preview.

  • New Features

    • Adapter-based router with SoroswapAdapter, PhoenixAdapter, and SdexAdapter.
    • Graph pathfinding (BFS, up to 3 hops) with a cached PoolRegistry and live reserve refresh.
    • Routed swaps via strategy: "best-route"; existing swap() stays unchanged.
    • Route preview with getSwapRoute() and routed_swap support in the contract tool.
    • New exports: SwapRouter, PoolRegistry, TokenGraph, SoroswapAdapter, PhoenixAdapter, SdexAdapter, getNetworkConfig, MAINNET_CONFIG, TESTNET_CONFIG.
  • Migration

    • No breaking changes.
    • To use routing: call agent.swap({ tokenIn, tokenOut, amount, slippage, strategy: "best-route", maxHops }). Use agent.getSwapRoute(...) to preview.

Written for commit 173cb53. Summary will update on new commits.

Add a swap router that finds the best multi-hop trade routes across
all major Stellar liquidity sources (Soroswap, Phoenix, SDEX) with
mainnet support.

Key features:
- Adapter pattern for pluggable DEX integrations
- Graph-based pathfinding (BFS with hop limit) for optimal routes
- Pool registry with time-based caching and live reserve refresh
- Backward-compatible API: existing swap() calls work unchanged
- New strategy: "best-route" opt-in for routed swaps
- Route preview via getSwapRoute() without executing
- LangChain tool support via routed_swap action

New files:
- router/types.ts - Core interfaces (DexAdapter, Pool, Quote, Route)
- router/config.ts - Network configs (mainnet/testnet)
- router/graph.ts - Token graph with AMM math and pathfinding
- router/registry.ts - Pool registry with caching
- router/router.ts - SwapRouter orchestrator
- router/adapters/soroswap.ts - Soroswap DEX adapter
- router/adapters/phoenix.ts - Phoenix DEX adapter
- router/adapters/sdex.ts - SDEX adapter (Horizon order book)

Modified files:
- agent.ts - Added strategy param, getSwapRoute(), lazy router init
- index.ts - Export router modules
- tools/contract.ts - Added routed_swap action
- utils/buildTransaction.ts - Added "route" operation type
@gitguardian
Copy link
Copy Markdown

gitguardian Bot commented Mar 30, 2026

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic High Entropy Secret 173cb53 tests/unit/router/types.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

7 issues found across 22 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/unit/router/adapters/sdex.test.ts">

<violation number="1" location="tests/unit/router/adapters/sdex.test.ts:45">
P2: Pool discovery test uses a tautological length assertion, so it cannot catch regressions where no pools are discovered.</violation>
</file>

<file name="router/adapters/sdex.ts">

<violation number="1" location="router/adapters/sdex.ts:92">
P2: Converting bigint amounts to Number before formatting can lose precision for large trades, producing incorrect Horizon quotes and slippage limits. Format the 7-decimal amount directly from bigint (or use a bigint-capable decimal library) to avoid precision loss.</violation>

<violation number="2" location="router/adapters/sdex.ts:146">
P1: SDEX swap operation uses a hardcoded placeholder destination, but the router never overwrites it before submission, so swaps will target the placeholder account or fail.</violation>
</file>

<file name="router/router.ts">

<violation number="1" location="router/router.ts:55">
P2: Converting bigint expectedAmountOut to Number can lose precision for large on-chain amounts, producing an incorrect minOut and undermining slippage protection.</violation>

<violation number="2" location="router/router.ts:61">
P2: Route execution always uses the precomputed leg.amountIn for each swap, so if a prior leg yields less than expected (but above minOut), the next leg can fail due to insufficient balance. This breaks multi-hop execution under slippage because actual outputs aren’t propagated to subsequent legs.</violation>
</file>

<file name="router/registry.ts">

<violation number="1" location="router/registry.ts:31">
P2: Cache is bypassed when the pool list is empty, so callers will re-run discovery on every getPools() call even within refreshIntervalMs. This defeats the refresh interval for valid empty states and can hammer adapters.</violation>
</file>

<file name="router/graph.ts">

<violation number="1" location="router/graph.ts:34">
P2: Converting bigint reserves/amounts to Number in price impact calculation can lose precision for large pools, producing incorrect price impact values.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread router/adapters/sdex.ts
const op = Operation.pathPaymentStrictSend({
sendAsset: sourceAsset,
sendAmount: sendAmount,
destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", // placeholder, set by router
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P1: SDEX swap operation uses a hardcoded placeholder destination, but the router never overwrites it before submission, so swaps will target the placeholder account or fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/adapters/sdex.ts, line 146:

<comment>SDEX swap operation uses a hardcoded placeholder destination, but the router never overwrites it before submission, so swaps will target the placeholder account or fail.</comment>

<file context>
@@ -0,0 +1,154 @@
+    const op = Operation.pathPaymentStrictSend({
+      sendAsset: sourceAsset,
+      sendAmount: sendAmount,
+      destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", // placeholder, set by router
+      destAsset: destAsset,
+      destMin: destMin,
</file context>
Fix with Cubic

});

const pools = await adapter.discoverPools();
expect(pools.length).toBeGreaterThanOrEqual(0);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Pool discovery test uses a tautological length assertion, so it cannot catch regressions where no pools are discovered.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/unit/router/adapters/sdex.test.ts, line 45:

<comment>Pool discovery test uses a tautological length assertion, so it cannot catch regressions where no pools are discovered.</comment>

<file context>
@@ -0,0 +1,78 @@
+    });
+
+    const pools = await adapter.discoverPools();
+    expect(pools.length).toBeGreaterThanOrEqual(0);
+  });
+
</file context>
Suggested change
expect(pools.length).toBeGreaterThanOrEqual(0);
expect(pools.length).toBeGreaterThan(0);
Fix with Cubic

Comment thread router/adapters/sdex.ts
const sourceParams = assetToQueryParams(sourceAsset);
const destParams = assetToQueryParams(destAsset);

const amountStr = (Number(amountIn) / 10_000_000).toFixed(7);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Converting bigint amounts to Number before formatting can lose precision for large trades, producing incorrect Horizon quotes and slippage limits. Format the 7-decimal amount directly from bigint (or use a bigint-capable decimal library) to avoid precision loss.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/adapters/sdex.ts, line 92:

<comment>Converting bigint amounts to Number before formatting can lose precision for large trades, producing incorrect Horizon quotes and slippage limits. Format the 7-decimal amount directly from bigint (or use a bigint-capable decimal library) to avoid precision loss.</comment>

<file context>
@@ -0,0 +1,154 @@
+    const sourceParams = assetToQueryParams(sourceAsset);
+    const destParams = assetToQueryParams(destAsset);
+
+    const amountStr = (Number(amountIn) / 10_000_000).toFixed(7);
+
+    const url = `${this.config.horizonUrl}/paths/strict-send?source_${sourceParams}&source_amount=${amountStr}&destination_${destParams}`;
</file context>
Fix with Cubic

Comment thread router/router.ts

const slippageMultiplier = 1 - slippage / 100;
const minOut = BigInt(
Math.floor(Number(leg.expectedAmountOut) * slippageMultiplier)
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Converting bigint expectedAmountOut to Number can lose precision for large on-chain amounts, producing an incorrect minOut and undermining slippage protection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/router.ts, line 55:

<comment>Converting bigint expectedAmountOut to Number can lose precision for large on-chain amounts, producing an incorrect minOut and undermining slippage protection.</comment>

<file context>
@@ -0,0 +1,110 @@
+
+      const slippageMultiplier = 1 - slippage / 100;
+      const minOut = BigInt(
+        Math.floor(Number(leg.expectedAmountOut) * slippageMultiplier)
+      );
+
</file context>
Fix with Cubic

Comment thread router/router.ts
const op = await adapter.buildSwapOp(
leg.pool,
leg.tokenIn,
leg.amountIn,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Route execution always uses the precomputed leg.amountIn for each swap, so if a prior leg yields less than expected (but above minOut), the next leg can fail due to insufficient balance. This breaks multi-hop execution under slippage because actual outputs aren’t propagated to subsequent legs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/router.ts, line 61:

<comment>Route execution always uses the precomputed leg.amountIn for each swap, so if a prior leg yields less than expected (but above minOut), the next leg can fail due to insufficient balance. This breaks multi-hop execution under slippage because actual outputs aren’t propagated to subsequent legs.</comment>

<file context>
@@ -0,0 +1,110 @@
+      const op = await adapter.buildSwapOp(
+        leg.pool,
+        leg.tokenIn,
+        leg.amountIn,
+        minOut
+      );
</file context>
Fix with Cubic

Comment thread router/registry.ts

async getPools(): Promise<Pool[]> {
const now = Date.now();
if (this.pools.length > 0 && now - this.lastRefresh < this.refreshIntervalMs) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Cache is bypassed when the pool list is empty, so callers will re-run discovery on every getPools() call even within refreshIntervalMs. This defeats the refresh interval for valid empty states and can hammer adapters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/registry.ts, line 31:

<comment>Cache is bypassed when the pool list is empty, so callers will re-run discovery on every getPools() call even within refreshIntervalMs. This defeats the refresh interval for valid empty states and can hammer adapters.</comment>

<file context>
@@ -0,0 +1,64 @@
+
+  async getPools(): Promise<Pool[]> {
+    const now = Date.now();
+    if (this.pools.length > 0 && now - this.lastRefresh < this.refreshIntervalMs) {
+      return this.pools;
+    }
</file context>
Fix with Cubic

Comment thread router/graph.ts
reserveOut: bigint
): number {
if (reserveIn === BigInt(0) || reserveOut === BigInt(0)) return 100;
const spotPrice = Number(reserveOut) / Number(reserveIn);
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 30, 2026

Choose a reason for hiding this comment

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

P2: Converting bigint reserves/amounts to Number in price impact calculation can lose precision for large pools, producing incorrect price impact values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At router/graph.ts, line 34:

<comment>Converting bigint reserves/amounts to Number in price impact calculation can lose precision for large pools, producing incorrect price impact values.</comment>

<file context>
@@ -0,0 +1,185 @@
+  reserveOut: bigint
+): number {
+  if (reserveIn === BigInt(0) || reserveOut === BigInt(0)) return 100;
+  const spotPrice = Number(reserveOut) / Number(reserveIn);
+  const executionPrice = Number(amountOut) / Number(amountIn);
+  return Math.max(0, ((spotPrice - executionPrice) / spotPrice) * 100);
</file context>
Fix with Cubic

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