@mento-protocol/mento-sdk 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { ERC20_ABI } from '../../core/abis';
2
2
  import { RouteNotFoundError } from '../../core/errors';
3
- import { buildConnectivityStructures, generateAllRoutes, selectOptimalRoutes } from '../../utils/routeUtils';
3
+ import { buildConnectivityStructures, compareRoutePath, generateAllRoutes, selectOptimalRoutes, } from '../../utils/routeUtils';
4
4
  import { canonicalSymbolKey } from '../../utils/sortUtils';
5
5
  import { multicall } from '../../utils/multicall';
6
6
  /**
@@ -8,8 +8,8 @@ import { multicall } from '../../utils/multicall';
8
8
  * Handles route discovery for both direct (single-hop) and multi-hop trading paths.
9
9
  *
10
10
  * Routes are identified by their token pair and include the path of pools
11
- * needed to execute the trade. Multi-hop routes (up to 2 hops) are automatically
12
- * discovered when no direct route exists between two tokens.
11
+ * needed to execute the trade. The service discovers routes with at most three
12
+ * hops. It adds three-hop routes only when no direct or two-hop path exists.
13
13
  */
14
14
  export class RouteService {
15
15
  constructor(publicClient, chainId, poolService) {
@@ -77,7 +77,7 @@ export class RouteService {
77
77
  return routes;
78
78
  }
79
79
  /**
80
- * Discovers all tradable routes including multi-hop routes (up to 2 hops)
80
+ * Discovers all tradable routes with at most three hops
81
81
  * Uses cached data by default for instant results, or generates fresh from blockchain
82
82
  *
83
83
  * @param options - Configuration options
@@ -144,7 +144,7 @@ export class RouteService {
144
144
  * if (route.path.length === 1) {
145
145
  * console.log('Direct route available')
146
146
  * } else {
147
- * console.log('Two-hop route:', route.path)
147
+ * console.log('Multi-hop route:', route.path)
148
148
  * }
149
149
  * ```
150
150
  */
@@ -173,7 +173,7 @@ export class RouteService {
173
173
  }
174
174
  // Build connectivity structures for route finding
175
175
  const connectivity = buildConnectivityStructures(directRoutes);
176
- // Generate all possible routes (direct + 2-hop)
176
+ // Generate direct, two-hop, and eligible three-hop routes
177
177
  const allRoutes = generateAllRoutes(connectivity);
178
178
  // Select routes based on returnAllRoutes flag
179
179
  const selectedRoutes = selectOptimalRoutes(allRoutes, returnAllRoutes, connectivity.addrToSymbol);
@@ -208,7 +208,11 @@ export class RouteService {
208
208
  buildLookup(routes) {
209
209
  const lookup = new Map();
210
210
  for (const route of routes) {
211
- lookup.set(makeTokenPairKey(route.tokens[0].address, route.tokens[1].address), route);
211
+ const key = makeTokenPairKey(route.tokens[0].address, route.tokens[1].address);
212
+ const current = lookup.get(key);
213
+ if (!current || isPreferredCachedRoute(route, current)) {
214
+ lookup.set(key, route);
215
+ }
212
216
  }
213
217
  return lookup;
214
218
  }
@@ -266,3 +270,33 @@ function makeTokenPairKey(tokenA, tokenB) {
266
270
  const [first, second] = [tokenA.toLowerCase(), tokenB.toLowerCase()].sort();
267
271
  return `${first}:${second}`;
268
272
  }
273
+ function isPreferredCachedRoute(candidate, current) {
274
+ const candidateIsThreeHop = candidate.path.length === 3;
275
+ const currentIsThreeHop = current.path.length === 3;
276
+ // Three-hop routes must not compete with direct or two-hop routes. This also
277
+ // protects lookups from a stale or manually produced cache that violates the
278
+ // route-generation eligibility rule.
279
+ if (candidateIsThreeHop !== currentIsThreeHop) {
280
+ return currentIsThreeHop;
281
+ }
282
+ const candidateCost = getRouteCost(candidate);
283
+ const currentCost = getRouteCost(current);
284
+ // A measured cost is preferred over a route without cost data. When both
285
+ // costs are available, lower cost is preferred.
286
+ if (candidateCost !== undefined || currentCost !== undefined) {
287
+ if (candidateCost === undefined)
288
+ return false;
289
+ if (currentCost === undefined)
290
+ return true;
291
+ if (candidateCost !== currentCost)
292
+ return candidateCost < currentCost;
293
+ }
294
+ // Hop count and exact path ties use the shared deterministic route order.
295
+ return compareRoutePath(candidate, current) < 0;
296
+ }
297
+ function getRouteCost(route) {
298
+ if (!('costData' in route))
299
+ return undefined;
300
+ const cost = route.costData?.totalCostPercent;
301
+ return typeof cost === 'number' && Number.isFinite(cost) ? cost : undefined;
302
+ }
@@ -6,7 +6,7 @@
6
6
  * @param tokenIn - The input token address (determines swap direction)
7
7
  * @param _tokenOut - The output token address (unused but kept for API clarity)
8
8
  * @returns Array of RouterRoute objects for the contract call
9
- * @throws {Error} If path is empty, too long, or contains invalid pools
9
+ * @throws {Error} If path is empty or contains invalid pools
10
10
  *
11
11
  * @example
12
12
  * ```typescript
@@ -1,4 +1,5 @@
1
1
  import { canonicalSymbolKey } from './sortUtils';
2
+ const MAX_DISCOVERED_ROUTE_HOPS = 3;
2
3
  /**
3
4
  * Builds the connectivity data structures needed for route generation.
4
5
  *
@@ -79,15 +80,15 @@ export function buildConnectivityStructures(directRoutes) {
79
80
  return { addrToSymbol, directRouteMap, tokenGraph, directRoutes };
80
81
  }
81
82
  /**
82
- * Generates all possible routes (direct + two-hop) using connectivity data.
83
+ * Generates all possible routes (direct + two-hop + eligible three-hop) using
84
+ * connectivity data.
83
85
  *
84
86
  * This function implements a route discovery algorithm that:
85
87
  *
86
- * 1. **Adds all direct routes** (single-hop routes)
87
- * 2. **Discovers two-hop routes** using graph traversal:
88
- * - For each token A, find its neighbors (tokens directly connected)
89
- * - For each neighbor B, find B's neighbors
90
- * - If B connects to token C (C ≠ A), then A->B->C is a valid route
88
+ * 1. **Adds all direct routes** (single-hop routes).
89
+ * 2. **Discovers multi-hop candidates** with one bounded simple-path traversal.
90
+ * 3. **Keeps every two-hop route** and only the shortest routes with three or
91
+ * more hops. The current discovery limit is three hops.
91
92
  *
92
93
  * **Route Deduplication**: Multiple routes between the same token pair
93
94
  * are collected in arrays, allowing the selection algorithm to choose
@@ -122,39 +123,110 @@ export function generateAllRoutes(connectivityData) {
122
123
  }
123
124
  allRoutes.get(route.id).push(route);
124
125
  }
125
- // Step 2: Generate two-hop routes using graph traversal
126
- // Algorithm: For each token, explore all paths of length 2
126
+ // Step 2: Discover every simple multi-hop candidate in one bounded traversal.
127
+ const discoveredRoutes = [];
127
128
  // OUTER LOOP: "For each starting token..." (e.g., USDm, CELO, EURm, etc.)
128
- for (const [start, neighbors] of tokenGraph.entries()) {
129
- // MIDDLE LOOP: "Where can I go from the starting token?" (first hop)
130
- // Example: If start = USDm, neighbors might be [CELO, USDC, KESm]
131
- for (const intermediate of neighbors) {
132
- // Get all tokens reachable from this intermediate token (second hop destinations)
133
- const secondHopNeighbors = tokenGraph.get(intermediate);
134
- if (!secondHopNeighbors)
135
- continue;
136
- // INNER LOOP: "From the intermediate token, where can I go?" (second hop)
137
- // Example: If intermediate = CELO, secondHopNeighbors might be [USDm, EURm, BRLm]
138
- for (const end of secondHopNeighbors) {
139
- // Skip circular routes like USDm CELO → USDm (pointless)
140
- if (end === start)
141
- continue;
142
- // At this point we have a potential route: start → intermediate → end
143
- // Example: USDm → CELO → EURm
144
- // Try to create a valid two-hop trading pair from this route
145
- const twoHopRoute = createTwoHopRoute(start, intermediate, end, addrToSymbol, directRouteMap);
146
- // If we successfully created the pair, add it to our collection
147
- if (twoHopRoute) {
148
- if (!allRoutes.has(twoHopRoute.id)) {
149
- allRoutes.set(twoHopRoute.id, []);
150
- }
151
- allRoutes.get(twoHopRoute.id).push(twoHopRoute);
152
- }
153
- }
129
+ for (const start of tokenGraph.keys()) {
130
+ discoverSimplePaths(start, start, [], new Set([start]), new Set(), MAX_DISCOVERED_ROUTE_HOPS, tokenGraph, addrToSymbol, directRouteMap, (route, pathStart, pathEnd) => discoveredRoutes.push({ route, start: pathStart, end: pathEnd }));
131
+ }
132
+ // Step 3: Determine the minimum structural distance for every endpoint pair.
133
+ // Direct routes seed the map with one hop. The traversal supplies distances
134
+ // from two hops up to MAX_DISCOVERED_ROUTE_HOPS.
135
+ const minimumHopsByRouteId = new Map();
136
+ for (const routeId of allRoutes.keys())
137
+ minimumHopsByRouteId.set(routeId, 1);
138
+ for (const { route } of discoveredRoutes) {
139
+ const minimumHops = minimumHopsByRouteId.get(route.id);
140
+ if (minimumHops === undefined || route.path.length < minimumHops) {
141
+ minimumHopsByRouteId.set(route.id, route.path.length);
154
142
  }
155
143
  }
144
+ // Preserve existing two-hop alternatives, including alternatives for direct
145
+ // pairs. Routes with three or more hops are eligible only when they are the
146
+ // shortest structural path for their endpoint pair. Insert every two-hop
147
+ // route first to preserve the existing route and map order.
148
+ const seenGeneratedPaths = new Set();
149
+ for (const { route, start, end } of discoveredRoutes) {
150
+ if (route.path.length !== 2)
151
+ continue;
152
+ addGeneratedRoute(allRoutes, route, start, end, addrToSymbol, seenGeneratedPaths);
153
+ }
154
+ for (const { route, start, end } of discoveredRoutes) {
155
+ const minimumHops = minimumHopsByRouteId.get(route.id);
156
+ if (route.path.length < 3 || route.path.length !== minimumHops)
157
+ continue;
158
+ addGeneratedRoute(allRoutes, route, start, end, addrToSymbol, seenGeneratedPaths);
159
+ }
156
160
  return allRoutes;
157
161
  }
162
+ /**
163
+ * Walks a token graph up to `maxHops`, emitting only simple paths. The graph
164
+ * stores token connectivity while `directRouteMap` resolves each edge to the
165
+ * executable pool used by the route path.
166
+ */
167
+ function discoverSimplePaths(start, current, path, visitedTokens, visitedPools, maxHops, tokenGraph, addrToSymbol, directRouteMap, onRoute) {
168
+ if (path.length === maxHops)
169
+ return;
170
+ // RECURSIVE LOOP: "Where can I go from the current token?"
171
+ // Example: USDm → CELO then inspects CELO's neighbors, such as EURm.
172
+ for (const next of tokenGraph.get(current) ?? []) {
173
+ // Skip circular paths such as USDm → CELO → USDm.
174
+ if (visitedTokens.has(next))
175
+ continue;
176
+ const currentSymbol = addrToSymbol.get(current);
177
+ const nextSymbol = addrToSymbol.get(next);
178
+ if (!currentSymbol || !nextSymbol)
179
+ continue;
180
+ const pool = directRouteMap.get(canonicalSymbolKey(currentSymbol, nextSymbol));
181
+ if (!pool || visitedPools.has(poolSignature(pool)))
182
+ continue;
183
+ const nextPath = [...path, pool];
184
+ const nextVisitedTokens = new Set(visitedTokens).add(next);
185
+ const nextVisitedPools = new Set(visitedPools).add(poolSignature(pool));
186
+ // Two or more pools define a potential multi-hop route.
187
+ // Example: USDm → CELO → EURm is a two-hop route.
188
+ if (nextPath.length >= 2) {
189
+ const route = createRouteFromPath(start, next, nextPath, addrToSymbol);
190
+ if (route)
191
+ onRoute(route, start, next);
192
+ }
193
+ discoverSimplePaths(start, next, nextPath, nextVisitedTokens, nextVisitedPools, maxHops, tokenGraph, addrToSymbol, directRouteMap, onRoute);
194
+ }
195
+ }
196
+ function addGeneratedRoute(allRoutes, route, start, end, addrToSymbol, seenGeneratedPaths) {
197
+ const canonicalRoute = canonicalizeGeneratedRoute(route, start, end, addrToSymbol);
198
+ const signature = `${canonicalRoute.id}:${canonicalRoute.path.map(poolSignature).join('|')}`;
199
+ if (seenGeneratedPaths.has(signature))
200
+ return;
201
+ seenGeneratedPaths.add(signature);
202
+ if (!allRoutes.has(canonicalRoute.id))
203
+ allRoutes.set(canonicalRoute.id, []);
204
+ allRoutes.get(canonicalRoute.id).push(canonicalRoute);
205
+ }
206
+ function createRouteFromPath(startAddr, endAddr, path, addrToSymbol) {
207
+ const startSymbol = addrToSymbol.get(startAddr);
208
+ const endSymbol = addrToSymbol.get(endAddr);
209
+ if (!startSymbol || !endSymbol || startAddr === endAddr)
210
+ return null;
211
+ const routeId = canonicalSymbolKey(startSymbol, endSymbol);
212
+ const startToken = { address: startAddr, symbol: startSymbol };
213
+ const endToken = { address: endAddr, symbol: endSymbol };
214
+ return {
215
+ id: routeId,
216
+ tokens: startSymbol <= endSymbol ? [startToken, endToken] : [endToken, startToken],
217
+ path,
218
+ };
219
+ }
220
+ function canonicalizeGeneratedRoute(route, startAddr, endAddr, addrToSymbol) {
221
+ const startSymbol = addrToSymbol.get(startAddr);
222
+ const endSymbol = addrToSymbol.get(endAddr);
223
+ if (!startSymbol || !endSymbol || startSymbol < endSymbol)
224
+ return route;
225
+ return { ...route, path: [...route.path].reverse() };
226
+ }
227
+ function poolSignature(pool) {
228
+ return `${pool.poolAddr}:${pool.factoryAddr}`;
229
+ }
158
230
  /**
159
231
  * Creates a two-hop tradable pair if valid exchange hops exist.
160
232
  *
@@ -285,8 +357,10 @@ export function selectOptimalRoutes(allRoutes, returnAllRoutes, addrToSymbol) {
285
357
  /**
286
358
  * Selects the best route from candidates using cost data or fallback heuristics.
287
359
  *
288
- * This function implements a sophisticated route selection algorithm with
289
- * multiple optimization tiers:
360
+ * This function implements a tiered route selection algorithm.
361
+ *
362
+ * **Eligibility guard**:
363
+ * - Exclude three-hop candidates when a direct or two-hop candidate exists
290
364
  *
291
365
  * **Tier 1 - Cost-Based Optimization** (Preferred):
292
366
  * - Use routes with cost data (actual cost information)
@@ -301,8 +375,8 @@ export function selectOptimalRoutes(allRoutes, returnAllRoutes, addrToSymbol) {
301
375
  * - For two-hop routes, prefer those going through major stablecoins
302
376
  * - Major FX currencies like USDm and EURm typically have better liquidity
303
377
  *
304
- * **Tier 4 - First Available** (Last Resort):
305
- * - If no other heuristics apply, use the first route found
378
+ * **Tier 4 - Deterministic Path Order** (Last Resort):
379
+ * - If no other heuristic applies, use the route with the lowest stable path key
306
380
  *
307
381
  * @param candidates - Array of possible routes for the same token pair
308
382
  * @param assetMap - Asset map for token symbol lookups
@@ -321,37 +395,94 @@ export function selectOptimalRoutes(allRoutes, returnAllRoutes, addrToSymbol) {
321
395
  * ```
322
396
  */
323
397
  export function selectBestRoute(candidates, addrToSymbol) {
398
+ // A three-hop route is eligible only when no direct or two-hop candidate
399
+ // exists. Keep this invariant at selection time as a defense against stale
400
+ // or manually assembled candidate sets.
401
+ const shorterCandidates = candidates.filter((candidate) => candidate.path.length <= 2);
402
+ const eligibleCandidates = shorterCandidates.length > 0 ? shorterCandidates : candidates;
324
403
  // Tier 1: Prefer routes with cost data (lowest cost wins)
325
- const candidatesWithCost = candidates.filter(hasCostData);
404
+ const candidatesWithCost = eligibleCandidates.filter(hasCostData);
326
405
  if (candidatesWithCost.length > 0) {
327
- return candidatesWithCost.reduce((best, current) => current.costData.totalCostPercent < best.costData.totalCostPercent ? current : best);
406
+ return candidatesWithCost.reduce((best, current) => (compareCostedRoutes(current, best) < 0 ? current : best));
328
407
  }
329
408
  // Tier 2: Prefer direct routes (single-hop, lower risk)
330
- const directRoute = candidates.find((c) => c.path.length === 1);
331
- if (directRoute)
332
- return directRoute;
409
+ const directRoutes = eligibleCandidates.filter((candidate) => candidate.path.length === 1);
410
+ if (directRoutes.length > 0)
411
+ return [...directRoutes].sort(compareRoutePath)[0];
333
412
  // Tier 3: Prefer routes through major stablecoins (better liquidity)
334
413
  const stablecoins = ['USDm', 'EURm', 'USDC', 'USDT'];
335
- const routeWithStablecoin = candidates.find((candidate) => {
336
- const intermediateToken = getIntermediateToken(candidate);
337
- if (!intermediateToken)
338
- return false;
339
- const symbol = addrToSymbol.get(intermediateToken);
340
- return symbol && stablecoins.includes(symbol);
414
+ const routesWithStablecoin = eligibleCandidates.filter((candidate) => {
415
+ return getIntermediateTokens(candidate).some((token) => {
416
+ const symbol = addrToSymbol.get(token);
417
+ return symbol !== undefined && stablecoins.includes(symbol);
418
+ });
341
419
  });
342
- // Tier 4: Use first available route as last resort
343
- return routeWithStablecoin || candidates[0];
420
+ if (routesWithStablecoin.length > 0)
421
+ return [...routesWithStablecoin].sort(compareRoutePath)[0];
422
+ // Tier 4: Use a stable path key so discovery order cannot change the result.
423
+ return [...eligibleCandidates].sort(compareRoutePath)[0];
424
+ }
425
+ function compareCostedRoutes(candidate, current) {
426
+ const costDifference = candidate.costData.totalCostPercent - current.costData.totalCostPercent;
427
+ return costDifference || compareRoutePath(candidate, current);
428
+ }
429
+ /**
430
+ * Compares route paths by hop count and a stable pool identity key.
431
+ *
432
+ * Callers must apply their own higher-level ordering before this comparator,
433
+ * such as route ID or cost preference.
434
+ */
435
+ export function compareRoutePath(first, second) {
436
+ const hopDifference = first.path.length - second.path.length;
437
+ if (hopDifference !== 0)
438
+ return hopDifference;
439
+ const firstKey = deterministicRoutePathKey(first);
440
+ const secondKey = deterministicRoutePathKey(second);
441
+ if (firstKey < secondKey)
442
+ return -1;
443
+ if (firstKey > secondKey)
444
+ return 1;
445
+ return 0;
446
+ }
447
+ /**
448
+ * Returns the canonical, case-insensitive key for a route's pool path.
449
+ *
450
+ * Keep this ordering stable across fresh route selection, cache generation,
451
+ * and cached route lookup.
452
+ */
453
+ export function deterministicRoutePathKey(route) {
454
+ return route.path
455
+ .map((pool) => [pool.poolType, pool.factoryAddr, pool.poolAddr, pool.token0, pool.token1]
456
+ .map((value) => value.toLowerCase())
457
+ .join(':'))
458
+ .join('|');
344
459
  }
345
460
  /**
346
- * Extracts the intermediate token address from a two-hop route.
347
- * In a two-hop route A->B->C, this function finds token B (the intermediate).
461
+ * Extracts the first intermediate token address from a multi-hop route.
462
+ * In a two-hop route A->B->C, this function finds token B. For longer routes,
463
+ * it preserves the helper's original single-token return type and returns the
464
+ * first intermediate token.
348
465
  */
349
466
  export function getIntermediateToken(route) {
350
- // Find the common token between the two hops
351
- const [hop1, hop2] = route.path;
352
- const hop1Tokens = [hop1.token0, hop1.token1];
353
- const hop2Tokens = [hop2.token0, hop2.token1];
354
- return hop1Tokens.find((addr) => hop2Tokens.includes(addr));
467
+ return getIntermediateTokens(route)[0];
468
+ }
469
+ /**
470
+ * Returns every intermediate token in an executable multi-hop path. Adjacent
471
+ * pools must share exactly one token for that token to be considered part of
472
+ * the path.
473
+ */
474
+ export function getIntermediateTokens(route) {
475
+ const intermediateTokens = [];
476
+ for (let index = 0; index < route.path.length - 1; index++) {
477
+ const currentPool = route.path[index];
478
+ const nextPool = route.path[index + 1];
479
+ const nextTokens = new Set([nextPool.token0, nextPool.token1]);
480
+ const sharedTokens = [currentPool.token0, currentPool.token1].filter((token) => nextTokens.has(token));
481
+ if (sharedTokens.length !== 1)
482
+ return [];
483
+ intermediateTokens.push(sharedTokens[0]);
484
+ }
485
+ return intermediateTokens;
355
486
  }
356
487
  /**
357
488
  * Type guard to check if a Route has cost data.
@@ -16,8 +16,8 @@ export interface RouteOptions {
16
16
  * Handles route discovery for both direct (single-hop) and multi-hop trading paths.
17
17
  *
18
18
  * Routes are identified by their token pair and include the path of pools
19
- * needed to execute the trade. Multi-hop routes (up to 2 hops) are automatically
20
- * discovered when no direct route exists between two tokens.
19
+ * needed to execute the trade. The service discovers routes with at most three
20
+ * hops. It adds three-hop routes only when no direct or two-hop path exists.
21
21
  */
22
22
  export declare class RouteService {
23
23
  private publicClient;
@@ -43,7 +43,7 @@ export declare class RouteService {
43
43
  */
44
44
  getDirectRoutes(): Promise<Route[]>;
45
45
  /**
46
- * Discovers all tradable routes including multi-hop routes (up to 2 hops)
46
+ * Discovers all tradable routes with at most three hops
47
47
  * Uses cached data by default for instant results, or generates fresh from blockchain
48
48
  *
49
49
  * @param options - Configuration options
@@ -83,7 +83,7 @@ export declare class RouteService {
83
83
  * if (route.path.length === 1) {
84
84
  * console.log('Direct route available')
85
85
  * } else {
86
- * console.log('Two-hop route:', route.path)
86
+ * console.log('Multi-hop route:', route.path)
87
87
  * }
88
88
  * ```
89
89
  */
@@ -44,8 +44,8 @@ const multicall_1 = require("../../utils/multicall");
44
44
  * Handles route discovery for both direct (single-hop) and multi-hop trading paths.
45
45
  *
46
46
  * Routes are identified by their token pair and include the path of pools
47
- * needed to execute the trade. Multi-hop routes (up to 2 hops) are automatically
48
- * discovered when no direct route exists between two tokens.
47
+ * needed to execute the trade. The service discovers routes with at most three
48
+ * hops. It adds three-hop routes only when no direct or two-hop path exists.
49
49
  */
50
50
  class RouteService {
51
51
  constructor(publicClient, chainId, poolService) {
@@ -113,7 +113,7 @@ class RouteService {
113
113
  return routes;
114
114
  }
115
115
  /**
116
- * Discovers all tradable routes including multi-hop routes (up to 2 hops)
116
+ * Discovers all tradable routes with at most three hops
117
117
  * Uses cached data by default for instant results, or generates fresh from blockchain
118
118
  *
119
119
  * @param options - Configuration options
@@ -180,7 +180,7 @@ class RouteService {
180
180
  * if (route.path.length === 1) {
181
181
  * console.log('Direct route available')
182
182
  * } else {
183
- * console.log('Two-hop route:', route.path)
183
+ * console.log('Multi-hop route:', route.path)
184
184
  * }
185
185
  * ```
186
186
  */
@@ -209,7 +209,7 @@ class RouteService {
209
209
  }
210
210
  // Build connectivity structures for route finding
211
211
  const connectivity = (0, routeUtils_1.buildConnectivityStructures)(directRoutes);
212
- // Generate all possible routes (direct + 2-hop)
212
+ // Generate direct, two-hop, and eligible three-hop routes
213
213
  const allRoutes = (0, routeUtils_1.generateAllRoutes)(connectivity);
214
214
  // Select routes based on returnAllRoutes flag
215
215
  const selectedRoutes = (0, routeUtils_1.selectOptimalRoutes)(allRoutes, returnAllRoutes, connectivity.addrToSymbol);
@@ -244,7 +244,11 @@ class RouteService {
244
244
  buildLookup(routes) {
245
245
  const lookup = new Map();
246
246
  for (const route of routes) {
247
- lookup.set(makeTokenPairKey(route.tokens[0].address, route.tokens[1].address), route);
247
+ const key = makeTokenPairKey(route.tokens[0].address, route.tokens[1].address);
248
+ const current = lookup.get(key);
249
+ if (!current || isPreferredCachedRoute(route, current)) {
250
+ lookup.set(key, route);
251
+ }
248
252
  }
249
253
  return lookup;
250
254
  }
@@ -303,4 +307,34 @@ function makeTokenPairKey(tokenA, tokenB) {
303
307
  const [first, second] = [tokenA.toLowerCase(), tokenB.toLowerCase()].sort();
304
308
  return `${first}:${second}`;
305
309
  }
310
+ function isPreferredCachedRoute(candidate, current) {
311
+ const candidateIsThreeHop = candidate.path.length === 3;
312
+ const currentIsThreeHop = current.path.length === 3;
313
+ // Three-hop routes must not compete with direct or two-hop routes. This also
314
+ // protects lookups from a stale or manually produced cache that violates the
315
+ // route-generation eligibility rule.
316
+ if (candidateIsThreeHop !== currentIsThreeHop) {
317
+ return currentIsThreeHop;
318
+ }
319
+ const candidateCost = getRouteCost(candidate);
320
+ const currentCost = getRouteCost(current);
321
+ // A measured cost is preferred over a route without cost data. When both
322
+ // costs are available, lower cost is preferred.
323
+ if (candidateCost !== undefined || currentCost !== undefined) {
324
+ if (candidateCost === undefined)
325
+ return false;
326
+ if (currentCost === undefined)
327
+ return true;
328
+ if (candidateCost !== currentCost)
329
+ return candidateCost < currentCost;
330
+ }
331
+ // Hop count and exact path ties use the shared deterministic route order.
332
+ return (0, routeUtils_1.compareRoutePath)(candidate, current) < 0;
333
+ }
334
+ function getRouteCost(route) {
335
+ if (!('costData' in route))
336
+ return undefined;
337
+ const cost = route.costData?.totalCostPercent;
338
+ return typeof cost === 'number' && Number.isFinite(cost) ? cost : undefined;
339
+ }
306
340
  //# sourceMappingURL=RouteService.js.map
@@ -21,7 +21,7 @@ export type ReadonlyRouterRoutes = readonly {
21
21
  * @param tokenIn - The input token address (determines swap direction)
22
22
  * @param _tokenOut - The output token address (unused but kept for API clarity)
23
23
  * @returns Array of RouterRoute objects for the contract call
24
- * @throws {Error} If path is empty, too long, or contains invalid pools
24
+ * @throws {Error} If path is empty or contains invalid pools
25
25
  *
26
26
  * @example
27
27
  * ```typescript
@@ -9,7 +9,7 @@ exports.encodeRoutePath = encodeRoutePath;
9
9
  * @param tokenIn - The input token address (determines swap direction)
10
10
  * @param _tokenOut - The output token address (unused but kept for API clarity)
11
11
  * @returns Array of RouterRoute objects for the contract call
12
- * @throws {Error} If path is empty, too long, or contains invalid pools
12
+ * @throws {Error} If path is empty or contains invalid pools
13
13
  *
14
14
  * @example
15
15
  * ```typescript
@@ -11,12 +11,12 @@ type Address = string;
11
11
  * The main workflow is:
12
12
  *
13
13
  * 1. Build connectivity structures from direct trading pairs
14
- * 2. Generate all possible routes (direct + two-hop)
14
+ * 2. Generate all possible routes (direct + two-hop + eligible three-hop)
15
15
  * 3. Select optimal routes using cost data or heuristics
16
16
  *
17
17
  * ALGORITHM OVERVIEW:
18
18
  * - Creates a graph where tokens are nodes and direct exchanges are edges
19
- * - Uses graph traversal to find two-hop routes through intermediate tokens
19
+ * - Uses bounded graph traversal to find simple routes through intermediate tokens
20
20
  * - Optimizes route selection based on cost data when available
21
21
  * - Falls back to heuristics (prefer direct routes, major stablecoins)
22
22
  * =============================================================================
@@ -130,15 +130,15 @@ export interface ConnectivityData {
130
130
  */
131
131
  export declare function buildConnectivityStructures(directRoutes: Route[]): ConnectivityData;
132
132
  /**
133
- * Generates all possible routes (direct + two-hop) using connectivity data.
133
+ * Generates all possible routes (direct + two-hop + eligible three-hop) using
134
+ * connectivity data.
134
135
  *
135
136
  * This function implements a route discovery algorithm that:
136
137
  *
137
- * 1. **Adds all direct routes** (single-hop routes)
138
- * 2. **Discovers two-hop routes** using graph traversal:
139
- * - For each token A, find its neighbors (tokens directly connected)
140
- * - For each neighbor B, find B's neighbors
141
- * - If B connects to token C (C ≠ A), then A->B->C is a valid route
138
+ * 1. **Adds all direct routes** (single-hop routes).
139
+ * 2. **Discovers multi-hop candidates** with one bounded simple-path traversal.
140
+ * 3. **Keeps every two-hop route** and only the shortest routes with three or
141
+ * more hops. The current discovery limit is three hops.
142
142
  *
143
143
  * **Route Deduplication**: Multiple routes between the same token pair
144
144
  * are collected in arrays, allowing the selection algorithm to choose
@@ -246,8 +246,10 @@ export declare function selectOptimalRoutes(allRoutes: Map<RouteID, Route[]>, re
246
246
  /**
247
247
  * Selects the best route from candidates using cost data or fallback heuristics.
248
248
  *
249
- * This function implements a sophisticated route selection algorithm with
250
- * multiple optimization tiers:
249
+ * This function implements a tiered route selection algorithm.
250
+ *
251
+ * **Eligibility guard**:
252
+ * - Exclude three-hop candidates when a direct or two-hop candidate exists
251
253
  *
252
254
  * **Tier 1 - Cost-Based Optimization** (Preferred):
253
255
  * - Use routes with cost data (actual cost information)
@@ -262,8 +264,8 @@ export declare function selectOptimalRoutes(allRoutes: Map<RouteID, Route[]>, re
262
264
  * - For two-hop routes, prefer those going through major stablecoins
263
265
  * - Major FX currencies like USDm and EURm typically have better liquidity
264
266
  *
265
- * **Tier 4 - First Available** (Last Resort):
266
- * - If no other heuristics apply, use the first route found
267
+ * **Tier 4 - Deterministic Path Order** (Last Resort):
268
+ * - If no other heuristic applies, use the route with the lowest stable path key
267
269
  *
268
270
  * @param candidates - Array of possible routes for the same token pair
269
271
  * @param assetMap - Asset map for token symbol lookups
@@ -283,10 +285,32 @@ export declare function selectOptimalRoutes(allRoutes: Map<RouteID, Route[]>, re
283
285
  */
284
286
  export declare function selectBestRoute(candidates: Route[], addrToSymbol: Map<Address, TokenSymbol>): Route | RouteWithCost;
285
287
  /**
286
- * Extracts the intermediate token address from a two-hop route.
287
- * In a two-hop route A->B->C, this function finds token B (the intermediate).
288
+ * Compares route paths by hop count and a stable pool identity key.
289
+ *
290
+ * Callers must apply their own higher-level ordering before this comparator,
291
+ * such as route ID or cost preference.
292
+ */
293
+ export declare function compareRoutePath(first: Route, second: Route): number;
294
+ /**
295
+ * Returns the canonical, case-insensitive key for a route's pool path.
296
+ *
297
+ * Keep this ordering stable across fresh route selection, cache generation,
298
+ * and cached route lookup.
299
+ */
300
+ export declare function deterministicRoutePathKey(route: Route): string;
301
+ /**
302
+ * Extracts the first intermediate token address from a multi-hop route.
303
+ * In a two-hop route A->B->C, this function finds token B. For longer routes,
304
+ * it preserves the helper's original single-token return type and returns the
305
+ * first intermediate token.
288
306
  */
289
307
  export declare function getIntermediateToken(route: Route): Address | undefined;
308
+ /**
309
+ * Returns every intermediate token in an executable multi-hop path. Adjacent
310
+ * pools must share exactly one token for that token to be considered part of
311
+ * the path.
312
+ */
313
+ export declare function getIntermediateTokens(route: Route): Address[];
290
314
  /**
291
315
  * Type guard to check if a Route has cost data.
292
316
  */