Skip to main content

fynd_core/algorithm/
most_liquid.rs

1//! Most Liquid algorithm implementation.
2//!
3//! This algorithm finds routes by:
4//! 1. Finding every route between the two tokens as a sequence of tokens, no pools chosen yet
5//! 2. Ranking those sequences by spot price and liquidity depth, deepest first
6//! 3. Solving each sequence hop by hop, taking the pool that pays most at the amount that reaches
7//!    it — one simulation per pool per hop rather than per pool combination, and never the same
8//!    pool on two hops
9//! 4. Ranking the solved sequences by net output (output less gas, in the output token)
10//! 5. Building swaps for the winner alone, with stats recorded to the tracing span
11
12use std::time::{Duration, Instant};
13
14use metrics::{counter, histogram};
15use num_bigint::{BigInt, BigUint};
16use num_traits::ToPrimitive;
17use petgraph::stable_graph::NodeIndex;
18use rustc_hash::{FxHashMap, FxHashSet};
19use smallvec::SmallVec;
20use tracing::{debug, instrument, trace};
21use tycho_simulation::{
22    tycho_common::simulation::protocol_sim::{Price, ProtocolSim},
23    tycho_core::models::{token::Token, Address},
24};
25
26use super::{Algorithm, AlgorithmConfig, NoPathReason};
27use crate::{
28    algorithm::{
29        paths,
30        sim_guard::GuardedProtocolSim,
31        swap_cache::{PoolDirection, Refusal, SwapCache, SwapResult},
32    },
33    derived::{computation::ComputationRequirements, types::TokenGasPrices, SharedDerivedDataRef},
34    feed::market_data::{MarketData, MarketState, StateLabel},
35    graph::{
36        EdgeData, GraphQueryFilter, TokenPath, TopologyGraph, TopologyGraphManager, INLINE_EDGES,
37    },
38    types::{ComponentId, Order, Route, RouteResult, Swap},
39    AlgorithmError,
40};
41
42/// What the simulation report calls the pass that picks a route. Most-liquid has one: every swap
43/// it makes is ranking candidates, and the winner is re-simulated by `build_route` afterwards.
44const SELECTION: &str = "selection";
45
46/// What can go wrong settling one token sequence.
47///
48/// Never leaves most-liquid: the caller counts it and tries the next sequence. Variants carry node
49/// indices rather than addresses, so a sequence that fails costs nothing to report.
50#[derive(Debug, thiserror::Error)]
51enum MostLiquidError {
52    /// No pool trading this pair could take the amount that reached it.
53    #[error("no pool between {from:?} and {to:?} could trade the amount")]
54    HopNotTradable { from: NodeIndex, to: NodeIndex },
55    /// A token on the sequence is not in the market subset this solve was given.
56    #[error("token {0:?} not in the market subset")]
57    TokenMissing(NodeIndex),
58}
59
60/// What every candidate on one order is solved against.
61///
62/// Fixed for the whole solve, so it travels as one argument rather than as five that would have to
63/// stay in step.
64#[derive(Clone, Copy)]
65struct SolveContext<'a> {
66    graph: &'a TopologyGraph<DepthAndPrice>,
67    market: &'a MarketState,
68    token_prices: Option<&'a TokenGasPrices>,
69    amount_in: &'a BigUint,
70    /// What a unit of gas costs, read once off the market snapshot.
71    gas_price: &'a BigUint,
72    /// When the solve started, against which every candidate checks the timeout.
73    start: Instant,
74}
75
76/// A token sequence with every hop solved, before any swap is built.
77struct SolvedRoute {
78    hops: SmallVec<[HopResult; INLINE_EDGES]>,
79    /// The route's output less the gas it costs, in the output token's own units. Falls back to
80    /// the gross amount when that token has no price, which is what happens before derived data
81    /// has been computed.
82    net_amount_out: BigInt,
83}
84
85/// One half of a token's gas price as an `f64`, for normalising depth.
86///
87/// `None` when it is zero or too large for an `f64`, either of which makes the edge unusable.
88/// `part` names which half, for the log.
89fn price_part(
90    value: &BigUint,
91    part: &'static str,
92    component_id: &ComponentId,
93    token_in: &Token,
94) -> Option<f64> {
95    match value.to_f64() {
96        Some(v) if v > 0.0 => Some(v),
97        Some(_) => {
98            trace!(
99                component_id = %component_id,
100                token_in = %token_in.address,
101                part,
102                "token price part is zero, skipping edge"
103            );
104            None
105        }
106        None => {
107            trace!(
108                component_id = %component_id,
109                token_in = %token_in.address,
110                part,
111                "token price part overflows f64, skipping edge"
112            );
113            None
114        }
115    }
116}
117
118/// How one solve went: what it looked at, what it dropped, and why.
119///
120/// Filled in as the solve runs, then handed to [`SolveReport::record`] once, which owns every
121/// metric and every log line the solve emits.
122#[derive(Default)]
123struct SolveReport {
124    /// Token sequences the graph search returned.
125    paths_candidates: usize,
126    /// Of those, the ones left after ranking and the `max_routes` cut.
127    paths_to_simulate: usize,
128    /// Of those, the ones reached before the timeout stopped the solve.
129    paths_simulated: usize,
130    /// Sequences the ranking could not place at all, so they were never simulated.
131    scoring_failures: usize,
132    /// Sequences with a hop no pool would trade.
133    simulation_failures: usize,
134    /// Winners whose swaps did not form a route the executor can take.
135    validation_failures: usize,
136}
137
138impl SolveReport {
139    /// How much of what was worth simulating actually got simulated, as a percentage.
140    fn coverage_pct(&self) -> f64 {
141        (self.paths_simulated as f64 / self.paths_to_simulate as f64) * 100.0
142    }
143
144    /// Records the counters and writes the line that says how the solve went.
145    fn record(
146        &self,
147        best: Option<&RouteResult>,
148        market: &MarketState,
149        amount_in: &BigUint,
150        solve_time_ms: u64,
151        components_considered: usize,
152    ) {
153        counter!("algorithm.scoring_failures").increment(self.scoring_failures as u64);
154        counter!("algorithm.simulation_failures").increment(self.simulation_failures as u64);
155        counter!("algorithm.validation_failures").increment(self.validation_failures as u64);
156        histogram!("algorithm.simulation_coverage_pct").record(self.coverage_pct());
157
158        let block_number = market
159            .last_updated()
160            .map(|b| b.number());
161        let tokens_considered = market.token_registry_ref().len();
162        let Some(result) = best else {
163            debug!(
164                solve_time_ms,
165                block_number,
166                paths_candidates = self.paths_candidates,
167                paths_to_simulate = self.paths_to_simulate,
168                paths_simulated = self.paths_simulated,
169                simulation_failures = self.simulation_failures,
170                validation_failures = self.validation_failures,
171                simulation_coverage_pct = self.coverage_pct(),
172                components_considered,
173                tokens_considered,
174                "no viable route"
175            );
176            return;
177        };
178
179        let path_desc = result
180            .route()
181            .path_description(market.token_registry_ref());
182        let protocols = result
183            .route()
184            .swaps()
185            .iter()
186            .map(|s| s.protocol())
187            .collect::<Vec<_>>();
188        let price = amount_in
189            .to_f64()
190            .filter(|&v| v > 0.0)
191            .and_then(|amt_in| {
192                result
193                    .net_amount_out()
194                    .to_f64()
195                    .map(|amt_out| amt_out / amt_in)
196            })
197            .unwrap_or(f64::NAN);
198
199        debug!(
200            solve_time_ms,
201            block_number,
202            paths_candidates = self.paths_candidates,
203            paths_to_simulate = self.paths_to_simulate,
204            paths_simulated = self.paths_simulated,
205            simulation_failures = self.simulation_failures,
206            validation_failures = self.validation_failures,
207            simulation_coverage_pct = self.coverage_pct(),
208            components_considered,
209            tokens_considered,
210            path = %path_desc,
211            amount_in = %amount_in,
212            net_amount_out = %result.net_amount_out(),
213            price_out_per_in = price,
214            hop_count = result.route().swaps().len(),
215            protocols = ?protocols,
216            "route found"
217        );
218    }
219}
220
221/// A built route's output less what its gas costs, in the output token.
222///
223/// Read off the swaps rather than off the solve because [`MostLiquidAlgorithm::build_route`]
224/// re-simulates with state overrides, so its numbers — not the solve's — are the ones the caller
225/// is handed. Falls back to the gross amount when the output token has no price, which is what
226/// happens before derived data has been computed.
227fn swap_on_route(
228    route: &Route,
229    token_prices: Option<&TokenGasPrices>,
230    gas_price: &BigUint,
231) -> BigInt {
232    let Some(last) = route.swaps().last() else {
233        return BigInt::ZERO;
234    };
235    let amount_out = BigInt::from(last.amount_out().clone());
236
237    let Some(price) = token_prices.and_then(|prices| prices.get(last.token_out())) else {
238        return amount_out;
239    };
240    let mut gas = BigUint::ZERO;
241    for swap in route.swaps() {
242        gas += swap.gas_estimate();
243    }
244
245    amount_out - BigInt::from(gas * gas_price * &price.numerator / &price.denominator)
246}
247
248/// What one pool paid for one input amount, on one token pair.
249///
250/// Holds no pool state and no component: a candidate route is solved to compare it, and only the
251/// winner is built into swaps.
252#[derive(Clone)]
253struct HopResult {
254    /// Where the pool these numbers came from sits in the pair's pool list.
255    pool_ix: usize,
256    /// What that pool paid out, before gas.
257    amount_out: BigUint,
258    /// What that swap costs in gas, in wei.
259    gas: BigUint,
260}
261
262/// Algorithm that selects routes based on expected output after gas.
263pub struct MostLiquidAlgorithm {
264    /// The hop bounds and connector tokens every route search runs under. Owned, so a solve hands
265    /// it straight to the graph instead of assembling one per order.
266    query: GraphQueryFilter,
267    timeout: Duration,
268    max_routes: Option<usize>,
269    cache_pair_swaps: bool,
270}
271
272/// Algorithm-specific edge data for liquidity-based routing.
273///
274/// Used by the MostLiquid algorithm to score paths based on expected output.
275/// Contains the spot price and liquidity depth.
276/// Note that the fee is included in the spot price already.
277#[derive(Debug, Clone, Default)]
278pub struct DepthAndPrice {
279    /// Spot price (token_out per token_in) for this edge direction.
280    pub spot_price: f64,
281    /// Liquidity depth normalized to gas token (native token) units.
282    pub depth: f64,
283}
284
285impl DepthAndPrice {
286    /// Creates a new DepthAndPrice with all fields set.
287    #[cfg(test)]
288    pub fn new(spot_price: f64, depth: f64) -> Self {
289        Self { spot_price, depth }
290    }
291
292    /// Compute depth and spot price from a live protocol simulation.
293    #[cfg(test)]
294    pub fn from_protocol_sim<S: ProtocolSim + ?Sized>(
295        sim: &S,
296        token_in: &Token,
297        token_out: &Token,
298    ) -> Result<Self, AlgorithmError> {
299        Ok(Self {
300            spot_price: sim
301                .spot_price(token_in, token_out)
302                .map_err(|e| {
303                    AlgorithmError::Other(format!("missing spot price for DepthAndPrice: {:?}", e))
304                })?,
305            depth: sim
306                .get_limits(token_in.address.clone(), token_out.address.clone())
307                .map_err(|e| {
308                    AlgorithmError::Other(format!("missing depth for DepthAndPrice: {:?}", e))
309                })?
310                .0
311                .to_f64()
312                .ok_or_else(|| {
313                    AlgorithmError::Other("depth conversion to f64 failed".to_string())
314                })?,
315        })
316    }
317}
318
319impl crate::graph::EdgeWeightFromSimAndDerived for DepthAndPrice {
320    fn from_sim_and_derived(
321        _sim: &dyn ProtocolSim,
322        component_id: &ComponentId,
323        token_in: &Token,
324        token_out: &Token,
325        derived: &crate::derived::DerivedData,
326    ) -> Option<Self> {
327        let key = (component_id.clone(), token_in.address.clone(), token_out.address.clone());
328
329        // Use pre-computed spot price; skip edge if unavailable.
330        let spot_price = match derived
331            .spot_prices()
332            .and_then(|p| p.get(&key).copied())
333        {
334            Some(p) => p,
335            None => {
336                trace!(component_id = %component_id, "spot price not found, skipping edge");
337                return None;
338            }
339        };
340
341        // Look up pre-computed depth; skip edge if unavailable.
342        let raw_depth = match derived
343            .component_depths()
344            .and_then(|d| d.get(&key))
345        {
346            Some(d) => d.to_f64().unwrap_or(0.0),
347            None => {
348                trace!(component_id = %component_id, "component depth not found, skipping edge");
349                return None;
350            }
351        };
352
353        // Normalize depth from raw token_in units to gas token units.
354        // TokenGasPrices stores Price { numerator, denominator } where
355        // numerator/denominator = "token units per gas token unit".
356        // To convert to gas token: depth_gas = raw_depth * denominator / numerator.
357        let depth = match derived
358            .token_prices()
359            .and_then(|p| p.get(&token_in.address))
360        {
361            Some(price) => {
362                let num = price_part(&price.numerator, "numerator", component_id, token_in)?;
363                let den = price_part(&price.denominator, "denominator", component_id, token_in)?;
364                raw_depth * den / num
365            }
366            None => {
367                trace!(
368                    component_id = %component_id,
369                    token_in = %token_in.address,
370                    "token price not found, skipping edge"
371                );
372                return None;
373            }
374        };
375
376        Some(Self { spot_price, depth })
377    }
378}
379
380impl MostLiquidAlgorithm {
381    /// Creates a new MostLiquidAlgorithm with default settings.
382    pub fn new() -> Self {
383        Self {
384            query: GraphQueryFilter { min_hops: 1, max_hops: 3, connector_tokens: None },
385            timeout: Duration::from_millis(500),
386            max_routes: None,
387            cache_pair_swaps: true,
388        }
389    }
390
391    /// Creates a new MostLiquidAlgorithm with custom settings.
392    pub fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
393        if config.min_hops() == 0 || config.min_hops() > config.max_hops() {
394            return Err(AlgorithmError::InvalidConfiguration {
395                reason: format!(
396                    "invalid hop configuration: min_hops={} max_hops={}",
397                    config.min_hops(),
398                    config.max_hops()
399                ),
400            });
401        }
402
403        Ok(Self {
404            query: GraphQueryFilter {
405                min_hops: config.min_hops(),
406                max_hops: config.max_hops(),
407                connector_tokens: config.connector_tokens().cloned(),
408            },
409            timeout: config.timeout(),
410            max_routes: config.max_routes(),
411            cache_pair_swaps: true,
412        })
413    }
414
415    /// Ranks a token sequence without simulating it, by the best its hops could do.
416    ///
417    /// Every pool on a hop is read as the market holds it, with nothing carried between hops. That
418    /// is exact here because [`MostLiquidAlgorithm::solve_token_path`] never takes the same pool
419    /// twice, so no hop is scored against a pool an earlier hop would have moved.
420    ///
421    /// The same shape as [`MostLiquidAlgorithm::try_score_path`] — spot prices multiplied, thinnest
422    /// depth as the bottleneck — but a hop here stands for several pools, so it is scored on its
423    /// best. That makes the figure an upper bound on any route through the sequence, which is what
424    /// a ranking wants: no sequence is pushed down the queue below one that cannot beat it.
425    ///
426    /// A hop whose pools all lack derived data scores as unmeasured, which sinks the sequence to
427    /// the bottom of the queue without removing it. Depth is what the ranking is made of, so a
428    /// sequence missing it cannot be placed -- but it can still be simulated, and simulation is
429    /// what decides.
430    ///
431    /// Returns `None` only when the sequence names a pair the graph has no pool for, which would be
432    /// the two indexes disagreeing rather than a routing outcome.
433    fn score_token_path(
434        graph: &TopologyGraph<DepthAndPrice>,
435        token_path: &[NodeIndex],
436    ) -> Option<f64> {
437        if token_path.len() < 2 {
438            return None;
439        }
440
441        let mut price = 1.0;
442        let mut min_depth = f64::MAX;
443
444        for pair in token_path.windows(2) {
445            let pools = graph.pools_between(pair[0], pair[1]);
446            if pools.is_empty() {
447                return None;
448            }
449
450            let mut best_price = f64::MIN;
451            let mut best_depth = f64::MIN;
452            for pool in pools {
453                if let Some(data) = pool.data.as_ref() {
454                    best_price = best_price.max(data.spot_price);
455                    best_depth = best_depth.max(data.depth);
456                }
457            }
458
459            if best_price == f64::MIN {
460                // Nothing measured on this hop. Neutral on price, thinnest possible on depth, so
461                // the sequence sinks to the bottom of the queue rather than out of it.
462                min_depth = 0.0;
463            } else {
464                price *= best_price;
465                min_depth = min_depth.min(best_depth);
466            }
467        }
468
469        Some(price * min_depth)
470    }
471
472    /// Builds the route the caller chose, once, from the pools it picked.
473    ///
474    /// Each hop is simulated again rather than carrying its result through the comparison: the
475    /// numbers come out the same, and a swap needs the pool's state as it was before it, which
476    /// nothing else has to hold on to. The state overrides are a backstop only -- the solve hands
477    /// over a route whose hops name distinct pools -- and they cover protocols whose components
478    /// share state behind the scenes.
479    fn build_route(
480        ctx: &SolveContext<'_>,
481        token_path: &[NodeIndex],
482        solved: &SolvedRoute,
483    ) -> Result<Route, AlgorithmError> {
484        let SolveContext { graph, market, amount_in, .. } = *ctx;
485        let mut current_amount = amount_in.clone();
486        let mut swaps = Vec::with_capacity(solved.hops.len());
487        let mut tokens: FxHashMap<Address, Token> = FxHashMap::default();
488        let mut state_overrides: FxHashMap<ComponentId, Box<dyn ProtocolSim>> =
489            FxHashMap::default();
490
491        for (pair, hop) in token_path.windows(2).zip(&solved.hops) {
492            let address_in = &graph[pair[0]];
493            let address_out = &graph[pair[1]];
494            let token_in = paths::get_token(market, address_in)?;
495            let token_out = paths::get_token(market, address_out)?;
496
497            let component_id = graph
498                .pools_between(pair[0], pair[1])
499                .get(hop.pool_ix)
500                .map(|edge| &edge.component_id)
501                .ok_or_else(|| AlgorithmError::DataNotFound {
502                    kind: "pool",
503                    id: Some(format!("{address_in:?} -> {address_out:?}")),
504                })?;
505            let component = market
506                .get_component(component_id)
507                .ok_or_else(|| AlgorithmError::DataNotFound {
508                    kind: "component",
509                    id: Some(component_id.clone()),
510                })?;
511            let state = state_overrides
512                .get(component_id)
513                .map(Box::as_ref)
514                .or_else(|| market.get_simulation_state(component_id))
515                .ok_or_else(|| AlgorithmError::DataNotFound {
516                    kind: "simulation state",
517                    id: Some(component_id.clone()),
518                })?;
519            let result = state
520                .get_amount_out_guarded(current_amount.clone(), token_in, token_out)
521                .map_err(|e| AlgorithmError::Other(format!("simulation error: {e:?}")))?;
522
523            swaps.push(Swap::new(
524                component_id.clone(),
525                component.protocol_system.clone(),
526                token_in.address.clone(),
527                token_out.address.clone(),
528                current_amount.clone(),
529                result.amount.clone(),
530                result.gas,
531                component.clone(),
532                state.clone_box(),
533            ));
534            tokens
535                .entry(token_in.address.clone())
536                .or_insert_with(|| token_in.clone());
537            tokens
538                .entry(token_out.address.clone())
539                .or_insert_with(|| token_out.clone());
540
541            state_overrides.insert(component_id.clone(), result.new_state);
542            current_amount = result.amount;
543        }
544
545        Ok(Route::new(swaps, tokens)?)
546    }
547
548    /// Ranks token sequences by [`MostLiquidAlgorithm::score_token_path`], best first.
549    ///
550    /// A sequence whose pools carry no derived data still scores — as zero depth — and is still
551    /// simulated. Only a sequence with a pair no pool trades at all drops out here.
552    fn score_paths(
553        graph: &TopologyGraph<DepthAndPrice>,
554        all_paths: Vec<TokenPath>,
555    ) -> Vec<(TokenPath, f64)> {
556        let mut scored_paths: Vec<(TokenPath, f64)> = all_paths
557            .into_iter()
558            .filter_map(|path| {
559                let score = Self::score_token_path(graph, &path)?;
560                Some((path, score))
561            })
562            .collect();
563
564        scored_paths.sort_by(|(_, a_score), (_, b_score)| {
565            // Flip the comparison to get descending order
566            b_score
567                .partial_cmp(a_score)
568                .unwrap_or(std::cmp::Ordering::Equal)
569        });
570
571        scored_paths
572    }
573
574    async fn snapshot_market_state(
575        graph: &TopologyGraph<DepthAndPrice>,
576        market: MarketData,
577        label: Option<StateLabel>,
578        scored_paths: &[(TokenPath, f64)],
579    ) -> Result<MarketState, AlgorithmError> {
580        let mut pairs: FxHashSet<(NodeIndex, NodeIndex)> = FxHashSet::default();
581        for (token_path, _) in scored_paths {
582            for pair in token_path.windows(2) {
583                pairs.insert((pair[0], pair[1]));
584            }
585        }
586        let mut component_ids: FxHashSet<&ComponentId> = FxHashSet::default();
587        for &(from, to) in &pairs {
588            for pool in graph.pools_between(from, to) {
589                component_ids.insert(&pool.component_id);
590            }
591        }
592
593        let market = paths::read_market(&market, label).await?;
594        let market_subset = market.extract_subset_with_overlay(&component_ids);
595        drop(market);
596        Ok(market_subset)
597    }
598
599    fn solve_for_best_path(
600        &self,
601        scored_paths: &[(TokenPath, f64)],
602        report: &mut SolveReport,
603        ctx: &SolveContext,
604    ) -> Result<RouteResult, AlgorithmError> {
605        let mut best_route: Option<(&TokenPath, SolvedRoute)> = None;
606        let mut winners = PairWinners::new(self.cache_pair_swaps);
607        let mut swaps = SwapCache::new();
608        let timeout_ms = self.timeout.as_millis() as u64;
609
610        for (token_path, _) in scored_paths {
611            // Check timeout
612            let elapsed_ms = ctx.start.elapsed().as_millis() as u64;
613            if elapsed_ms > timeout_ms {
614                break;
615            }
616
617            let solved = match Self::solve_token_path(ctx, token_path, &mut winners, &mut swaps) {
618                Ok(solved) => solved,
619                Err(e) => {
620                    trace!(error = %e, "could not solve path");
621                    report.simulation_failures += 1;
622                    continue;
623                }
624            };
625
626            // Check if this is the best result so far
627            if best_route
628                .as_ref()
629                .is_none_or(|(_, previous): &(&TokenPath, SolvedRoute)| {
630                    solved.net_amount_out > previous.net_amount_out
631                })
632            {
633                best_route = Some((token_path, solved));
634            }
635
636            report.paths_simulated += 1;
637        }
638
639        // Only the winner is built into swaps: that is what copies a component and a pool state per
640        // hop, and every other candidate would have thrown them away.
641        let best = match best_route {
642            Some((token_path, solved)) => {
643                let route = Self::build_route(ctx, token_path, &solved)?;
644                if let Err(e) = route.validate() {
645                    trace!(error = %e, "best route failed validation");
646                    report.validation_failures += 1;
647                    None
648                } else {
649                    let amount_out = swap_on_route(&route, ctx.token_prices, ctx.gas_price);
650                    Some(RouteResult::new(route, amount_out, ctx.gas_price.clone()))
651                }
652            }
653            None => None,
654        };
655
656        let solve_time_ms = ctx.start.elapsed().as_millis() as u64;
657        report.record(
658            best.as_ref(),
659            ctx.market,
660            ctx.amount_in,
661            solve_time_ms,
662            ctx.market.component_count(),
663        );
664
665        match best {
666            Some(best_route) => Ok(best_route),
667            None => {
668                if solve_time_ms > timeout_ms {
669                    Err(AlgorithmError::Timeout { elapsed_ms: solve_time_ms })
670                } else {
671                    Err(AlgorithmError::InsufficientLiquidity)
672                }
673            }
674        }
675    }
676
677    /// Solves a fixed token sequence, choosing the pool to swap through on each hop.
678    ///
679    /// Walks the hops in order and, at each, simulates every pool serving that pair at the amount
680    /// actually arriving and keeps whichever nets most after its own gas. Enumerating the
681    /// combinations instead costs the product of the pools per hop; this costs their sum.
682    ///
683    /// Choosing hop by hop is not a shortcut that gives up accuracy. More into a pool is more out
684    /// of it, so the largest amount at each hop carries through to the largest amount at the
685    /// end. Gas does not disturb that: the gas already spent is common to every candidate at a
686    /// hop and drops out of the comparison, and a hop's own gas priced in the token it pays out
687    /// ranks the candidates the same way as comparing whole routes would. What it does improve
688    /// on is the ranking heuristic, which never sees the order size — here every choice is made
689    /// at the amount really flowing.
690    ///
691    /// Falls back to comparing gross output when a token has no price, which is exact for gross.
692    ///
693    /// A pool an earlier hop swapped through is not offered to a later one. Each hop is simulated
694    /// against the pool's state as the market holds it, so a pool taken twice would be quoted the
695    /// second time as if the first swap had not moved it. Two hops of one sequence never name the
696    /// same token pair, but one component can serve two pairs -- a pool holding A, B and C is on
697    /// both hops of A -> B -> C -- and a circular sequence crosses the same pair in both
698    /// directions. Sequences whose hops all run out of pools this way are dropped, the same as any
699    /// other sequence that cannot be settled.
700    fn solve_token_path<'g>(
701        ctx: &SolveContext<'g>,
702        token_path: &[NodeIndex],
703        winners: &mut PairWinners,
704        swaps: &mut SwapCache<'g>,
705    ) -> Result<SolvedRoute, MostLiquidError> {
706        let (graph, market, gas_price) = (ctx.graph, ctx.market, ctx.gas_price);
707        let mut current_amount = ctx.amount_in.clone();
708        let mut hops: SmallVec<[HopResult; INLINE_EDGES]> = SmallVec::new();
709        let mut used_components: SmallVec<[&ComponentId; INLINE_EDGES]> = SmallVec::new();
710        let mut gas = BigUint::ZERO;
711        // What a unit of gas costs in the token the route pays out, which is the last hop's own
712        // output token. `None` when that token has no price.
713        let mut route_gas_price = None;
714
715        for pair in token_path.windows(2) {
716            let (token_in, token_out, token_out_gas_price) = Self::get_pair_data(ctx, pair)?;
717
718            let simulate = |component_id: &'g ComponentId| {
719                let paid = swaps.swap(
720                    PoolDirection {
721                        component_id,
722                        address_in: &token_in.address,
723                        address_out: &token_out.address,
724                    },
725                    &current_amount,
726                    SELECTION,
727                    || {
728                        let Some(state) = market.get_simulation_state(component_id) else {
729                            return Err(Refusal::Failed);
730                        };
731                        state
732                            .get_amount_out_guarded(current_amount.clone(), token_in, token_out)
733                            .map(|result| SwapResult { amount_out: result.amount, gas: result.gas })
734                            .map_err(|error| Refusal::of(&error))
735                    },
736                    true,
737                )?;
738                let net = match token_out_gas_price {
739                    Some(price) => {
740                        let cost = &paid.gas * gas_price * &price.numerator / &price.denominator;
741                        BigInt::from(paid.amount_out.clone()) - BigInt::from(cost)
742                    }
743                    None => BigInt::from(paid.amount_out.clone()),
744                };
745                Some((paid, net))
746            };
747
748            let pools = graph.pools_between(pair[0], pair[1]);
749
750            // A pool this route already crossed cannot be offered again. Where that bites, the
751            // pool is picked here and the cache is left alone in both directions: what it holds
752            // was chosen over every pool, and the best of a narrowed field is not the answer the
753            // next route to ask this pair at this amount should be handed.
754            let restricted = pools
755                .iter()
756                .any(|edge| used_components.contains(&&edge.component_id));
757            let hop_result = if restricted {
758                best_paying_pool(pools, |id| !used_components.contains(&id), simulate)
759            } else {
760                winners.hop((pair[0], pair[1]), pools, simulate)
761            };
762            let Some(hop_result) = hop_result else {
763                return Err(MostLiquidError::HopNotTradable { from: pair[0], to: pair[1] })
764            };
765
766            let chosen = pools
767                .get(hop_result.pool_ix)
768                .expect("Pool used not present in the pools available");
769
770            used_components.push(&chosen.component_id);
771
772            gas += &hop_result.gas;
773            current_amount = hop_result.amount_out.clone();
774            route_gas_price = token_out_gas_price;
775            hops.push(hop_result);
776        }
777
778        let net_amount_out = match route_gas_price {
779            Some(price) => {
780                let cost = gas * ctx.gas_price * &price.numerator / &price.denominator;
781                BigInt::from(current_amount) - BigInt::from(cost)
782            }
783            None => BigInt::from(current_amount),
784        };
785
786        Ok(SolvedRoute { hops, net_amount_out })
787    }
788
789    fn get_pair_data<'a>(
790        ctx: &SolveContext<'a>,
791        pair: &[NodeIndex],
792    ) -> Result<(&'a Token, &'a Token, Option<&'a Price>), MostLiquidError> {
793        let token_in = ctx
794            .market
795            .get_token(&ctx.graph[pair[0]])
796            .ok_or(MostLiquidError::TokenMissing(pair[0]))?;
797        let token_out = ctx
798            .market
799            .get_token(&ctx.graph[pair[1]])
800            .ok_or(MostLiquidError::TokenMissing(pair[1]))?;
801        let token_out_gas_price = ctx
802            .token_prices
803            .and_then(|prices| prices.get(&token_out.address));
804        Ok((token_in, token_out, token_out_gas_price))
805    }
806}
807
808impl Default for MostLiquidAlgorithm {
809    fn default() -> Self {
810        Self::new()
811    }
812}
813
814impl Algorithm for MostLiquidAlgorithm {
815    type GraphType = TopologyGraph<DepthAndPrice>;
816    type GraphManager = TopologyGraphManager<DepthAndPrice>;
817
818    fn name(&self) -> &str {
819        "most_liquid"
820    }
821
822    // TODO: Consider adding token pair symbols to the span for easier interpretation
823    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
824    async fn find_best_route(
825        &self,
826        graph: &Self::GraphType,
827        market: MarketData,
828        label: Option<StateLabel>,
829        derived: Option<SharedDerivedDataRef>,
830        order: &Order,
831    ) -> Result<RouteResult, AlgorithmError> {
832        let start = Instant::now();
833
834        // Exact-out isn't supported yet
835        if !order.is_sell() {
836            return Err(AlgorithmError::ExactOutNotSupported);
837        }
838
839        // Shared rather than copied: a solve only reads these, and the map covers every token in
840        // the market.
841        let token_prices = match derived.as_ref() {
842            Some(derived) => derived
843                .read()
844                .await
845                .token_prices_shared(),
846            None => None,
847        };
848
849        let amount_in = order.amount().clone();
850
851        // Step 1: Find every route as a sequence of tokens. Pools are chosen per hop during
852        // simulation.
853        let all_paths =
854            paths::find_token_paths(graph, order.token_in(), order.token_out(), &self.query)?;
855        let n_paths = all_paths.len();
856        let no_path = |reason| AlgorithmError::NoPath {
857            from: order.token_in().clone(),
858            to: order.token_out().clone(),
859            reason,
860        };
861        if all_paths.is_empty() {
862            return Err(no_path(NoPathReason::NoGraphPath));
863        }
864
865        // Step 2: Score and sort all paths by estimated output (higher score = better)
866        // No lock needed — scoring uses only local graph data.
867        let mut scored_paths = Self::score_paths(graph, all_paths);
868        if scored_paths.is_empty() {
869            return Err(no_path(NoPathReason::NoScorablePaths));
870        }
871
872        let mut report = SolveReport {
873            paths_candidates: n_paths,
874            scoring_failures: n_paths - scored_paths.len(),
875            ..SolveReport::default()
876        };
877
878        if let Some(max_routes) = self.max_routes {
879            scored_paths.truncate(max_routes);
880        }
881        report.paths_to_simulate = scored_paths.len();
882
883        // Step 3: Fetch all pools in scored_paths.
884        let market = Self::snapshot_market_state(graph, market, label, &scored_paths).await?;
885        let gas_price = paths::fetch_gas_price(&market)?;
886
887        // Step 4: Solve all paths in score order and return the best one
888        let ctx = SolveContext {
889            graph,
890            market: &market,
891            token_prices: token_prices.as_deref(),
892            amount_in: &amount_in,
893            gas_price: &gas_price,
894            start,
895        };
896
897        self.solve_for_best_path(&scored_paths, &mut report, &ctx)
898    }
899
900    fn computation_requirements(&self) -> ComputationRequirements {
901        // MostLiquidAlgorithm uses token prices for two purposes:
902        // 1. Converting gas costs from wei to output token terms (net_amount_out)
903        // 2. Normalizing component depth to gas token units for path scoring (from_sim_and_derived)
904        //
905        // Token prices are marked as `allow_stale` since they don't change much
906        // block-to-block. Stale prices affect scoring order (not correctness)
907        // and gas cost estimation accuracy.
908        ComputationRequirements::none()
909            .allow_stale("token_prices")
910            .expect("Conflicting Computation Requirements")
911    }
912
913    fn timeout(&self) -> Duration {
914        self.timeout
915    }
916}
917
918/// The pool on a pair paying the most net of gas, among those `usable` admits.
919///
920/// `pool_ix` indexes `pools`, so a caller that skipped some still gets back the index the built
921/// route needs.
922fn best_paying_pool<'g, D>(
923    pools: &'g [EdgeData<D>],
924    mut usable: impl FnMut(&ComponentId) -> bool,
925    mut simulate: impl FnMut(&'g ComponentId) -> Option<(SwapResult, BigInt)>,
926) -> Option<HopResult> {
927    let mut best: Option<(usize, SwapResult, BigInt)> = None;
928
929    for (pool_ix, edge) in pools.iter().enumerate() {
930        if !usable(&edge.component_id) {
931            continue;
932        }
933        let Some((result, net)) = simulate(&edge.component_id) else {
934            trace!(component_id = edge.component_id, "simulation failed, skipping pool");
935            continue;
936        };
937        if best
938            .as_ref()
939            .is_none_or(|(_, _, best_net)| net > *best_net)
940        {
941            best = Some((pool_ix, result, net));
942        }
943    }
944
945    let (pool_ix, paid, _) = best?;
946    Some(HopResult { pool_ix, amount_out: paid.amount_out, gas: paid.gas })
947}
948
949/// Which pool won each token pair, for this order only.
950///
951/// Routes share pairs: every route through WBTC -> WETH asks the same pools the same question.
952/// What each pool *paid* is not kept here -- that lives in the shared [`SwapCache`], keyed by pool
953/// so two nearby amounts on one pool's curve can be read across. Keeping amounts per pair could
954/// not do that: a later amount can be won by a different pool, and a line drawn between two
955/// amounts won by two pools joins two unrelated curves.
956///
957/// What is left is the choice, which is the part worth reusing: try the pool that won this pair
958/// last before asking every pool on it again.
959///
960/// Remembering nothing is a valid state: [`PairWinners::new`] takes a flag, and with it off every
961/// hop asks every pool, which is the answer this is an approximation of.
962struct PairWinners {
963    winner_by_pair: FxHashMap<(NodeIndex, NodeIndex), usize>,
964    enabled: bool,
965}
966
967impl PairWinners {
968    fn new(enabled: bool) -> Self {
969        Self { winner_by_pair: FxHashMap::default(), enabled }
970    }
971
972    /// Swaps one pair: which pool to go through, and what it pays.
973    ///
974    /// Tries the pool that won this pair before falling back to every pool. `simulate` reports
975    /// what one pool pays and what that is worth after its own gas; whether it reaches the pool or
976    /// is answered from amounts already asked is the swap cache's business, not this one's.
977    ///
978    /// Returns `None` when no pool on the pair can trade the amount.
979    fn hop<'g, D>(
980        &mut self,
981        pair: (NodeIndex, NodeIndex),
982        pools: &'g [EdgeData<D>],
983        mut simulate: impl FnMut(&'g ComponentId) -> Option<(SwapResult, BigInt)>,
984    ) -> Option<HopResult> {
985        // The winner is assumed to hold at a nearby amount. Where it does not, it still pays what
986        // it pays, and the next full scan of this pair moves the choice on.
987        if let Some(&pool_ix) = self.winner_by_pair.get(&pair) {
988            if let Some((paid, _)) = pools
989                .get(pool_ix)
990                .and_then(|edge| simulate(&edge.component_id))
991            {
992                return Some(HopResult { pool_ix, amount_out: paid.amount_out, gas: paid.gas });
993            }
994        }
995
996        let hop_result = best_paying_pool(pools, |_| true, simulate)?;
997        if self.enabled {
998            self.winner_by_pair
999                .insert(pair, hop_result.pool_ix);
1000        }
1001        Some(hop_result)
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use tycho_simulation::{
1008        tycho_core::simulation::protocol_sim::Price,
1009        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
1010    };
1011
1012    use super::*;
1013    use crate::{
1014        algorithm::test_utils::{
1015            addr, component,
1016            fixtures::{addrs, linear_graph},
1017            order, setup_market_weighted, token, MockProtocolSim, ONE_ETH,
1018        },
1019        derived::{
1020            computation::{FailedItem, FailedItemError},
1021            types::TokenGasPrices,
1022            DerivedData,
1023        },
1024        graph::GraphManager,
1025        types::OrderSide,
1026    };
1027
1028    fn wrap_market(market: MarketState) -> MarketData {
1029        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
1030    }
1031
1032    /// Creates a SharedDerivedDataRef with token prices set for testing.
1033    ///
1034    /// The price is set to numerator=1, denominator=1, which means:
1035    /// gas_cost_in_token = gas_cost_wei * 1 / 1 = gas_cost_wei
1036    fn setup_derived_with_token_prices(token_addresses: &[Address]) -> SharedDerivedDataRef {
1037        let mut token_prices: TokenGasPrices = FxHashMap::default();
1038        for addr in token_addresses {
1039            // Price where 1 wei of gas = 1 unit of token
1040            token_prices.insert(
1041                addr.clone(),
1042                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
1043            );
1044        }
1045
1046        let mut derived_data = DerivedData::new();
1047        derived_data.set_token_prices(token_prices, vec![], 1, true);
1048        std::sync::Arc::new(tokio::sync::RwLock::new(derived_data))
1049    }
1050
1051    fn make_mock_sim() -> MockProtocolSim {
1052        MockProtocolSim::new(2.0)
1053    }
1054
1055    fn pair_key(comp: &str, b_in: u8, b_out: u8) -> (String, Address, Address) {
1056        (comp.to_string(), addr(b_in), addr(b_out))
1057    }
1058
1059    fn pair_key_str(comp: &str, b_in: u8, b_out: u8) -> String {
1060        format!("{comp}/{}/{}", addr(b_in), addr(b_out))
1061    }
1062
1063    fn make_token_prices(addresses: &[Address]) -> TokenGasPrices {
1064        let mut prices = TokenGasPrices::default();
1065        for addr in addresses {
1066            // 1:1 price (1 token unit = 1 gas token unit)
1067            prices.insert(
1068                addr.clone(),
1069                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
1070            );
1071        }
1072        prices
1073    }
1074
1075    #[test]
1076    fn test_from_sim_and_derived_failed_spot_price_returns_none() {
1077        let key = pair_key("component1", 0x01, 0x02);
1078        let key_str = pair_key_str("component1", 0x01, 0x02);
1079        let tok_in = token(0x01, "A");
1080        let tok_out = token(0x02, "B");
1081
1082        let mut derived = DerivedData::new();
1083        // spot price fails, component depth not computed
1084        derived.set_spot_prices(
1085            Default::default(),
1086            vec![FailedItem {
1087                key: key_str,
1088                error: FailedItemError::SimulationFailed("sim error".into()),
1089            }],
1090            10,
1091            true,
1092        );
1093        derived.set_component_depths(Default::default(), vec![], 10, true);
1094        derived.set_token_prices(
1095            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
1096            vec![],
1097            10,
1098            true,
1099        );
1100
1101        let sim = make_mock_sim();
1102        let result =
1103            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1104                &sim, &key.0, &tok_in, &tok_out, &derived,
1105            );
1106
1107        assert!(result.is_none());
1108    }
1109
1110    #[test]
1111    fn test_from_sim_and_derived_failed_component_depth_returns_none() {
1112        let key = pair_key("component1", 0x01, 0x02);
1113        let key_str = pair_key_str("component1", 0x01, 0x02);
1114        let tok_in = token(0x01, "A");
1115        let tok_out = token(0x02, "B");
1116
1117        let mut derived = DerivedData::new();
1118        // spot price succeeds
1119        let mut prices = crate::derived::types::SpotPrices::default();
1120        prices.insert(key.clone(), 1.5);
1121        derived.set_spot_prices(prices, vec![], 10, true);
1122        // component depth fails
1123        derived.set_component_depths(
1124            Default::default(),
1125            vec![FailedItem {
1126                key: key_str,
1127                error: FailedItemError::SimulationFailed("depth error".into()),
1128            }],
1129            10,
1130            true,
1131        );
1132        derived.set_token_prices(
1133            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
1134            vec![],
1135            10,
1136            true,
1137        );
1138
1139        let sim = make_mock_sim();
1140        let result =
1141            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1142                &sim, &key.0, &tok_in, &tok_out, &derived,
1143            );
1144
1145        assert!(result.is_none());
1146    }
1147
1148    #[test]
1149    fn test_from_sim_and_derived_both_failed_returns_none() {
1150        let key = pair_key("component1", 0x01, 0x02);
1151        let key_str = pair_key_str("component1", 0x01, 0x02);
1152        let tok_in = token(0x01, "A");
1153        let tok_out = token(0x02, "B");
1154
1155        let mut derived = DerivedData::new();
1156        derived.set_spot_prices(
1157            Default::default(),
1158            vec![FailedItem {
1159                key: key_str.clone(),
1160                error: FailedItemError::SimulationFailed("spot error".into()),
1161            }],
1162            10,
1163            true,
1164        );
1165        derived.set_component_depths(
1166            Default::default(),
1167            vec![FailedItem {
1168                key: key_str,
1169                error: FailedItemError::SimulationFailed("depth error".into()),
1170            }],
1171            10,
1172            true,
1173        );
1174        derived.set_token_prices(
1175            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
1176            vec![],
1177            10,
1178            true,
1179        );
1180
1181        let sim = make_mock_sim();
1182        let result =
1183            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1184                &sim, &key.0, &tok_in, &tok_out, &derived,
1185            );
1186
1187        assert!(result.is_none());
1188    }
1189
1190    #[test]
1191    fn test_from_sim_and_derived_missing_token_price_returns_none() {
1192        let key = pair_key("component1", 0x01, 0x02);
1193        let tok_in = token(0x01, "A");
1194        let tok_out = token(0x02, "B");
1195
1196        let mut derived = DerivedData::new();
1197        // Spot price and component depth both present
1198        let mut prices = crate::derived::types::SpotPrices::default();
1199        prices.insert(key.clone(), 1.5);
1200        derived.set_spot_prices(prices, vec![], 10, true);
1201
1202        let mut depths = crate::derived::types::ComponentDepths::default();
1203        depths.insert(key.clone(), BigUint::from(1000u64));
1204        derived.set_component_depths(depths, vec![], 10, true);
1205
1206        // No token prices set — normalization should return None
1207
1208        let sim = make_mock_sim();
1209        let result =
1210            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1211                &sim, &key.0, &tok_in, &tok_out, &derived,
1212            );
1213
1214        assert!(
1215            result.is_none(),
1216            "should return None when token price is missing for depth normalization"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_from_sim_and_derived_normalizes_depth_to_eth() {
1222        let key = pair_key("component1", 0x01, 0x02);
1223        let tok_in = token(0x01, "A");
1224        let tok_out = token(0x02, "B");
1225
1226        let mut derived = DerivedData::new();
1227
1228        // Spot price
1229        let mut spot = crate::derived::types::SpotPrices::default();
1230        spot.insert(key.clone(), 2.0);
1231        derived.set_spot_prices(spot, vec![], 10, true);
1232
1233        // Raw depth: 2_000_000 token_in units
1234        let mut depths = crate::derived::types::ComponentDepths::default();
1235        depths.insert(key.clone(), BigUint::from(2_000_000u64));
1236        derived.set_component_depths(depths, vec![], 10, true);
1237
1238        // Token price: 2000 token_in per 1 ETH (numerator=2000, denominator=1)
1239        // So 2_000_000 raw units / 2000 = 1000 ETH
1240        let mut token_prices = TokenGasPrices::default();
1241        token_prices.insert(
1242            tok_in.address.clone(),
1243            Price { numerator: BigUint::from(2000u64), denominator: BigUint::from(1u64) },
1244        );
1245        derived.set_token_prices(token_prices, vec![], 10, true);
1246
1247        let sim = make_mock_sim();
1248        let result =
1249            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1250                &sim, &key.0, &tok_in, &tok_out, &derived,
1251            );
1252
1253        let data = result.expect("should return Some when all data present");
1254        assert!((data.spot_price - 2.0).abs() < f64::EPSILON, "spot price should be 2.0");
1255        // depth_in_eth = 2_000_000 * 1 / 2000 = 1000.0
1256        assert!(
1257            (data.depth - 1000.0).abs() < f64::EPSILON,
1258            "depth should be 1000.0 ETH, got {}",
1259            data.depth
1260        );
1261    }
1262
1263    #[test]
1264    fn test_from_sim_and_derived_normalizes_depth_fractional_price() {
1265        let key = pair_key("component1", 0x01, 0x02);
1266        let tok_in = token(0x01, "A");
1267        let tok_out = token(0x02, "B");
1268
1269        let mut derived = DerivedData::new();
1270
1271        let mut spot = crate::derived::types::SpotPrices::default();
1272        spot.insert(key.clone(), 0.5);
1273        derived.set_spot_prices(spot, vec![], 10, true);
1274
1275        // Raw depth: 500 token_in units
1276        let mut depths = crate::derived::types::ComponentDepths::default();
1277        depths.insert(key.clone(), BigUint::from(500u64));
1278        derived.set_component_depths(depths, vec![], 10, true);
1279
1280        // Token price: numerator=3, denominator=2 -> 1.5 tokens per ETH
1281        // depth_in_eth = 500 * 2 / 3 = 333.333...
1282        let mut token_prices = TokenGasPrices::default();
1283        token_prices.insert(
1284            tok_in.address.clone(),
1285            Price { numerator: BigUint::from(3u64), denominator: BigUint::from(2u64) },
1286        );
1287        derived.set_token_prices(token_prices, vec![], 10, true);
1288
1289        let sim = make_mock_sim();
1290        let result =
1291            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1292                &sim, &key.0, &tok_in, &tok_out, &derived,
1293            );
1294
1295        let data = result.expect("should return Some when all data present");
1296        let expected_depth = 500.0 * 2.0 / 3.0;
1297        assert!(
1298            (data.depth - expected_depth).abs() < 1e-10,
1299            "depth should be {expected_depth}, got {}",
1300            data.depth
1301        );
1302    }
1303
1304    // ==================== find_best_route Tests ====================
1305
1306    #[tokio::test]
1307    async fn test_find_best_route_single_path() {
1308        let token_a = token(0x01, "A");
1309        let token_b = token(0x02, "B");
1310
1311        let (market, manager) = setup_market_weighted(vec![(
1312            "component1",
1313            &token_a,
1314            &token_b,
1315            MockProtocolSim::new(2.0),
1316        )]);
1317
1318        let algorithm = MostLiquidAlgorithm::with_config(
1319            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1320        )
1321        .unwrap();
1322        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1323        let result = algorithm
1324            .find_best_route(manager.graph(), market, None, None, &order)
1325            .await
1326            .unwrap();
1327
1328        assert_eq!(result.route().swaps().len(), 1);
1329        assert_eq!(*result.route().swaps()[0].amount_in(), BigUint::from(ONE_ETH));
1330        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1331    }
1332
1333    #[tokio::test]
1334    async fn test_find_best_route_ranks_by_net_amount_out() {
1335        // Tests that route selection is based on net_amount_out (output - gas cost),
1336        // not just gross output. Three parallel components with different spot_price/gas combos:
1337        //
1338        // Gas price = 100 wei/gas (set by setup_market_weighted)
1339        //
1340        // | Component      | spot_price | gas | Output (1000 in) | Gas Cost (gas*100) | Net   |
1341        // |-----------|------------|-----|------------------|-------------------|-------|
1342        // | best      | 3          | 10  | 3000             | 1000              | 2000  |
1343        // | low_out   | 2          | 5   | 2000             | 500               | 1500  |
1344        // | high_gas  | 4          | 30  | 4000             | 3000              | 1000  |
1345        let token_a = token(0x01, "A");
1346        let token_b = token(0x02, "B");
1347
1348        let (market, manager) = setup_market_weighted(vec![
1349            ("best", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(10)),
1350            ("low_out", &token_a, &token_b, MockProtocolSim::new(2.0).with_gas(5)),
1351            ("high_gas", &token_a, &token_b, MockProtocolSim::new(4.0).with_gas(30)),
1352        ]);
1353
1354        let algorithm = MostLiquidAlgorithm::with_config(
1355            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1356        )
1357        .unwrap();
1358        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
1359
1360        // Set up derived data with token prices so gas can be deducted
1361        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1362
1363        let result = algorithm
1364            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1365            .await
1366            .unwrap();
1367
1368        // Should select "best" component for highest net_amount_out (2000)
1369        assert_eq!(result.route().swaps().len(), 1);
1370        assert_eq!(result.route().swaps()[0].component_id(), "best");
1371        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(3000u64));
1372        assert_eq!(result.net_amount_out(), &BigInt::from(2000)); // 3000 - 1000
1373    }
1374
1375    #[tokio::test]
1376    async fn test_find_best_route_no_path_returns_error() {
1377        let token_a = token(0x01, "A");
1378        let token_b = token(0x02, "B");
1379        let token_c = token(0x03, "C"); // Disconnected
1380
1381        let (market, manager) = setup_market_weighted(vec![(
1382            "component1",
1383            &token_a,
1384            &token_b,
1385            MockProtocolSim::new(2.0),
1386        )]);
1387
1388        let algorithm = MostLiquidAlgorithm::new();
1389        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1390
1391        let result = algorithm
1392            .find_best_route(manager.graph(), market, None, None, &order)
1393            .await;
1394        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1395    }
1396
1397    #[tokio::test]
1398    async fn test_find_best_route_multi_hop() {
1399        let token_a = token(0x01, "A");
1400        let token_b = token(0x02, "B");
1401        let token_c = token(0x03, "C");
1402
1403        let (market, manager) = setup_market_weighted(vec![
1404            ("component1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1405            ("component2", &token_b, &token_c, MockProtocolSim::new(3.0)),
1406        ]);
1407
1408        let algorithm = MostLiquidAlgorithm::with_config(
1409            AlgorithmConfig::new(1, 2, Duration::from_millis(100), None).unwrap(),
1410        )
1411        .unwrap();
1412        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1413
1414        let result = algorithm
1415            .find_best_route(manager.graph(), market, None, None, &order)
1416            .await
1417            .unwrap();
1418
1419        // A->B: ONE_ETH*2, B->C: (ONE_ETH*2)*3
1420        assert_eq!(result.route().swaps().len(), 2);
1421        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1422        assert_eq!(result.route().swaps()[0].component_id(), "component1".to_string());
1423        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(ONE_ETH * 2 * 3));
1424        assert_eq!(result.route().swaps()[1].component_id(), "component2".to_string());
1425    }
1426
1427    /// Builds a market and graph from components that may hold more than two tokens.
1428    ///
1429    /// [`setup_market_weighted`] takes one pair per component, which cannot express the pool that
1430    /// serves two hops of the same sequence. Sets no edge weights: an unranked sequence is still
1431    /// simulated, and these cases are about which pool a hop picks.
1432    fn setup_market_multi_token(
1433        components: Vec<(&str, Vec<Token>, MockProtocolSim)>,
1434        tokens: &[Token],
1435    ) -> (MarketData, TopologyGraphManager<DepthAndPrice>) {
1436        let mut market = MarketState::new();
1437        market.update_gas_price(BlockGasPrice {
1438            block_number: 1,
1439            block_hash: Default::default(),
1440            block_timestamp: 0,
1441            pricing: GasPrice::Legacy { gas_price: BigUint::from(1u64) },
1442        });
1443        market.upsert_tokens(tokens.to_vec());
1444        let protocol_components: Vec<_> = components
1445            .iter()
1446            .map(|(id, tokens, _)| component(id, tokens))
1447            .collect();
1448        market.upsert_components(protocol_components);
1449        let states: Vec<_> = components
1450            .into_iter()
1451            .map(|(id, _, sim)| (id.to_string(), Box::new(sim) as Box<dyn ProtocolSim>))
1452            .collect();
1453        market.update_states(states);
1454
1455        let mut manager = TopologyGraphManager::default();
1456        manager.initialize_graph(&market.component_topology());
1457        (wrap_market(market), manager)
1458    }
1459
1460    /// A pool serving two hops of one sequence is taken by the first of them only.
1461    ///
1462    /// `multi` holds all three tokens, so it trades both A -> B and B -> C, and it pays more than
1463    /// `bc` on the second hop. Taking it twice would price the second swap against a pool the
1464    /// first swap had already moved, so the second hop falls to `bc`.
1465    #[tokio::test]
1466    async fn test_find_best_route_never_takes_one_pool_twice() {
1467        let token_a = token(0x01, "A");
1468        let token_b = token(0x02, "B");
1469        let token_c = token(0x03, "C");
1470
1471        let (market, manager) = setup_market_multi_token(
1472            vec![
1473                (
1474                    "multi",
1475                    vec![token_a.clone(), token_b.clone(), token_c.clone()],
1476                    MockProtocolSim::new(3.0),
1477                ),
1478                ("bc", vec![token_b.clone(), token_c.clone()], MockProtocolSim::new(2.0)),
1479            ],
1480            &[token_a.clone(), token_b.clone(), token_c.clone()],
1481        );
1482
1483        // Two hops exactly, so the direct A -> C pool `multi` also offers is not the answer.
1484        let algorithm = MostLiquidAlgorithm::with_config(
1485            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1486        )
1487        .unwrap();
1488        let order = order(&token_a, &token_c, 1000, OrderSide::Sell);
1489
1490        let result = algorithm
1491            .find_best_route(manager.graph(), market, None, None, &order)
1492            .await
1493            .unwrap();
1494
1495        assert_eq!(result.route().swaps().len(), 2);
1496        assert_eq!(result.route().swaps()[0].component_id(), "multi");
1497        assert_eq!(result.route().swaps()[1].component_id(), "bc");
1498        // A -> B through multi: 1000 * 3. B -> C through bc: 3000 * 2.
1499        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(3000u64));
1500        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(6000u64));
1501    }
1502
1503    /// A sequence whose second hop has no pool left is dropped rather than solved.
1504    ///
1505    /// `multi` is the only pool on either hop of A -> B -> C, and the first hop takes it.
1506    #[tokio::test]
1507    async fn test_find_best_route_drops_sequence_with_no_pool_left() {
1508        let token_a = token(0x01, "A");
1509        let token_b = token(0x02, "B");
1510        let token_c = token(0x03, "C");
1511
1512        let (market, manager) = setup_market_multi_token(
1513            vec![(
1514                "multi",
1515                vec![token_a.clone(), token_b.clone(), token_c.clone()],
1516                MockProtocolSim::new(3.0),
1517            )],
1518            &[token_a.clone(), token_b.clone(), token_c.clone()],
1519        );
1520
1521        let algorithm = MostLiquidAlgorithm::with_config(
1522            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1523        )
1524        .unwrap();
1525        let order = order(&token_a, &token_c, 1000, OrderSide::Sell);
1526
1527        let result = algorithm
1528            .find_best_route(manager.graph(), market, None, None, &order)
1529            .await;
1530
1531        assert!(matches!(result, Err(AlgorithmError::InsufficientLiquidity)));
1532    }
1533
1534    /// A pool with no derived data is still a pool that trades.
1535    ///
1536    /// Depth is measured by binary search over simulations and gives up for a range of reasons --
1537    /// missing token metadata, a spot price too small to work with, a protocol that answers
1538    /// `get_limits` poorly. None of those say the pool would execute badly, only that it could not
1539    /// be measured, so it is ranked last and simulated rather than dropped.
1540    #[tokio::test]
1541    async fn test_find_best_route_uses_pools_without_edge_weights() {
1542        // Component1 has edge weights (scoreable), Component2 doesn't
1543        let token_a = token(0x01, "A");
1544        let token_b = token(0x02, "B");
1545
1546        // Set up market with both components using new API
1547        let mut market = MarketState::new();
1548        let component1_state = MockProtocolSim::new(2.0);
1549        let component2_state = MockProtocolSim::new(3.0); // Higher multiplier but no edge weight
1550
1551        let component1_comp = component("component1", &[token_a.clone(), token_b.clone()]);
1552        let component2_comp = component("component2", &[token_a.clone(), token_b.clone()]);
1553
1554        // Set gas price (required for simulation)
1555        market.update_gas_price(BlockGasPrice {
1556            block_number: 1,
1557            block_hash: Default::default(),
1558            block_timestamp: 0,
1559            pricing: GasPrice::Legacy { gas_price: BigUint::from(1u64) },
1560        });
1561
1562        // Insert components
1563        market.upsert_components(vec![component1_comp, component2_comp]);
1564
1565        // Insert states
1566        market.update_states(vec![
1567            ("component1".to_string(), Box::new(component1_state.clone()) as Box<dyn ProtocolSim>),
1568            ("component2".to_string(), Box::new(component2_state) as Box<dyn ProtocolSim>),
1569        ]);
1570
1571        // Insert tokens
1572        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1573
1574        // Initialize graph with both components
1575        let mut manager = TopologyGraphManager::default();
1576        manager.initialize_graph(&market.component_topology());
1577
1578        // Only set edge weights for component1, NOT component2
1579        let weight =
1580            DepthAndPrice::from_protocol_sim(&component1_state, &token_a, &token_b).unwrap();
1581        manager
1582            .set_pool_weight(
1583                &"component1".to_string(),
1584                &token_a.address,
1585                &token_b.address,
1586                weight,
1587                false,
1588            )
1589            .unwrap();
1590
1591        // Use max_hops=1 to focus only on direct 1-hop paths
1592        let algorithm = MostLiquidAlgorithm::with_config(
1593            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1594        )
1595        .unwrap();
1596        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1597        let market = wrap_market(market);
1598        let result = algorithm
1599            .find_best_route(manager.graph(), market, None, None, &order)
1600            .await
1601            .unwrap();
1602
1603        // component2 has no weight, so it never gets ranked -- but it pays 3x against component1's
1604        // 2x, and simulating both is what settles the hop.
1605        assert_eq!(result.route().swaps().len(), 1);
1606        assert_eq!(result.route().swaps()[0].component_id(), "component2");
1607        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 3));
1608    }
1609
1610    /// A market whose derived data has not been computed at all still routes.
1611    ///
1612    /// Ranking only decides what order routes are simulated in, so with nothing to rank by every
1613    /// route is still simulated.
1614    #[tokio::test]
1615    async fn test_find_best_route_without_any_derived_data() {
1616        // All paths exist but none have edge weights
1617        let token_a = token(0x01, "A");
1618        let token_b = token(0x02, "B");
1619
1620        let mut market = MarketState::new();
1621        let component_state = MockProtocolSim::new(2.0);
1622        let comp = component("component1", &[token_a.clone(), token_b.clone()]);
1623
1624        // Set gas price (required for simulation)
1625        market.update_gas_price(BlockGasPrice {
1626            block_number: 1,
1627            block_hash: Default::default(),
1628            block_timestamp: 0,
1629            pricing: GasPrice::Eip1559 {
1630                base_fee_per_gas: BigUint::from(1u64),
1631                max_priority_fee_per_gas: BigUint::from(0u64),
1632            },
1633        });
1634
1635        market.upsert_components(vec![comp]);
1636        market.update_states(vec![(
1637            "component1".to_string(),
1638            Box::new(component_state) as Box<dyn ProtocolSim>,
1639        )]);
1640        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1641
1642        // Initialize graph but DO NOT set any edge weights
1643        let mut manager = TopologyGraphManager::default();
1644        manager.initialize_graph(&market.component_topology());
1645
1646        let algorithm = MostLiquidAlgorithm::new();
1647        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1648        let market = wrap_market(market);
1649
1650        let result = algorithm
1651            .find_best_route(manager.graph(), market, None, None, &order)
1652            .await
1653            .expect("an unranked route is still a route");
1654
1655        assert_eq!(result.route().swaps().len(), 1);
1656        assert_eq!(result.route().swaps()[0].component_id(), "component1");
1657        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1658    }
1659
1660    #[tokio::test]
1661    async fn test_find_best_route_gas_exceeds_output_returns_negative_net() {
1662        let token_a = token(0x01, "A");
1663        let token_b = token(0x02, "B");
1664
1665        let (market, manager) = setup_market_weighted(vec![(
1666            "component1",
1667            &token_a,
1668            &token_b,
1669            MockProtocolSim::new(2.0),
1670        )]);
1671        let mut market_write = market.try_write().unwrap();
1672
1673        // Set a non-zero gas price so gas cost exceeds tiny output
1674        // gas_cost = 50_000 * (1_000_000 + 1_000_000) = 100_000_000_000 >> 2 wei output
1675        market_write.update_gas_price(BlockGasPrice {
1676            block_number: 1,
1677            block_hash: Default::default(),
1678            block_timestamp: 0,
1679            pricing: GasPrice::Eip1559 {
1680                base_fee_per_gas: BigUint::from(1_000_000u64),
1681                max_priority_fee_per_gas: BigUint::from(1_000_000u64),
1682            },
1683        });
1684        drop(market_write); // Release write lock
1685
1686        let algorithm = MostLiquidAlgorithm::new();
1687        let order = order(&token_a, &token_b, 1, OrderSide::Sell); // 1 wei input -> 2 wei output
1688
1689        // Set up derived data with token prices so gas can be deducted
1690        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1691
1692        // Route should still be returned, but with negative net_amount_out
1693        let result = algorithm
1694            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1695            .await
1696            .expect("should return route even with negative net_amount_out");
1697
1698        // Verify the route has swaps
1699        assert_eq!(result.route().swaps().len(), 1);
1700        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(2u64)); // 1 * 2 = 2 wei
1701
1702        // Verify it's: 2 - 200_000_000_000 = -199_999_999_998
1703        let expected_net = BigInt::from(2) - BigInt::from(100_000_000_000u64);
1704        assert_eq!(result.net_amount_out(), &expected_net);
1705    }
1706
1707    #[tokio::test]
1708    async fn test_find_best_route_insufficient_liquidity() {
1709        // Component has limited liquidity (1000 wei) but we try to swap ONE_ETH
1710        let token_a = token(0x01, "A");
1711        let token_b = token(0x02, "B");
1712
1713        let (market, manager) = setup_market_weighted(vec![(
1714            "component1",
1715            &token_a,
1716            &token_b,
1717            MockProtocolSim::new(2.0).with_liquidity(1000),
1718        )]);
1719
1720        let algorithm = MostLiquidAlgorithm::new();
1721        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell); // More than 1000 wei liquidity
1722
1723        let result = algorithm
1724            .find_best_route(manager.graph(), market, None, None, &order)
1725            .await;
1726        assert!(matches!(result, Err(AlgorithmError::InsufficientLiquidity)));
1727    }
1728
1729    #[tokio::test]
1730    async fn test_find_best_route_missing_gas_price_returns_error() {
1731        // Test that missing gas price returns DataNotFound error, not InsufficientLiquidity
1732        let token_a = token(0x01, "A");
1733        let token_b = token(0x02, "B");
1734
1735        let mut market = MarketState::new();
1736        let component_state = MockProtocolSim::new(2.0);
1737        let comp = component("component1", &[token_a.clone(), token_b.clone()]);
1738
1739        // DO NOT set gas price - this is what we're testing
1740        market.upsert_components(vec![comp]);
1741        market.update_states(vec![(
1742            "component1".to_string(),
1743            Box::new(component_state.clone()) as Box<dyn ProtocolSim>,
1744        )]);
1745        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1746
1747        // Initialize graph and set edge weights
1748        let mut manager = TopologyGraphManager::default();
1749        manager.initialize_graph(&market.component_topology());
1750        let weight =
1751            DepthAndPrice::from_protocol_sim(&component_state, &token_a, &token_b).unwrap();
1752        manager
1753            .set_pool_weight(
1754                &"component1".to_string(),
1755                &token_a.address,
1756                &token_b.address,
1757                weight,
1758                false,
1759            )
1760            .unwrap();
1761
1762        let algorithm = MostLiquidAlgorithm::new();
1763        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1764        let market = wrap_market(market);
1765
1766        let result = algorithm
1767            .find_best_route(manager.graph(), market, None, None, &order)
1768            .await;
1769
1770        // Should get DataNotFound for gas price, not InsufficientLiquidity
1771        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "gas price", .. })));
1772    }
1773
1774    /// A circle crosses the pair in both directions, so it needs a pool for each direction.
1775    #[tokio::test]
1776    async fn test_find_best_route_circular_arbitrage() {
1777        let token_a = token(0x01, "A");
1778        let token_b = token(0x02, "B");
1779
1780        // MockProtocolSim::get_amount_out multiplies by spot_price when token_in < token_out and
1781        // divides by it otherwise.
1782        let (market, manager) = setup_market_weighted(vec![
1783            ("component1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1784            ("component2", &token_a, &token_b, MockProtocolSim::new(3.0)),
1785        ]);
1786
1787        // Use min_hops=2 to require at least 2 hops (circular)
1788        let algorithm = MostLiquidAlgorithm::with_config(
1789            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1790        )
1791        .unwrap();
1792
1793        // Order: swap A for A (circular)
1794        let order = order(&token_a, &token_a, 100, OrderSide::Sell);
1795
1796        let result = algorithm
1797            .find_best_route(manager.graph(), market, None, None, &order)
1798            .await
1799            .unwrap();
1800
1801        // Should have 2 swaps forming a circle
1802        assert_eq!(result.route().swaps().len(), 2, "Should have 2 swaps for circular route");
1803
1804        // First swap: A -> B through component2, which pays most (100 * 3 = 300)
1805        assert_eq!(*result.route().swaps()[0].token_in(), token_a.address);
1806        assert_eq!(*result.route().swaps()[0].token_out(), token_b.address);
1807        assert_eq!(result.route().swaps()[0].component_id(), "component2");
1808        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(300u64));
1809
1810        // Second swap: B -> A. component2 is spent, so the hop falls to component1 (300 / 2 = 150)
1811        assert_eq!(*result.route().swaps()[1].token_in(), token_b.address);
1812        assert_eq!(*result.route().swaps()[1].token_out(), token_a.address);
1813        assert_eq!(result.route().swaps()[1].component_id(), "component1");
1814        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(150u64));
1815
1816        // Verify the route starts and ends with the same token
1817        assert_eq!(result.route().swaps()[0].token_in(), result.route().swaps()[1].token_out());
1818    }
1819
1820    /// One pool is not a circle: the return hop would have to cross the pool the outbound hop
1821    /// already moved, which is the reuse the solve refuses.
1822    #[tokio::test]
1823    async fn test_find_best_route_circular_needs_two_pools() {
1824        let token_a = token(0x01, "A");
1825        let token_b = token(0x02, "B");
1826
1827        let (market, manager) = setup_market_weighted(vec![(
1828            "component1",
1829            &token_a,
1830            &token_b,
1831            MockProtocolSim::new(2.0),
1832        )]);
1833
1834        let algorithm = MostLiquidAlgorithm::with_config(
1835            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1836        )
1837        .unwrap();
1838        let order = order(&token_a, &token_a, 100, OrderSide::Sell);
1839
1840        let result = algorithm
1841            .find_best_route(manager.graph(), market, None, None, &order)
1842            .await;
1843
1844        assert!(matches!(result, Err(AlgorithmError::InsufficientLiquidity)));
1845    }
1846
1847    #[tokio::test]
1848    async fn test_find_best_route_respects_min_hops() {
1849        // Setup: A->B (1-hop) and A->C->B (2-hop)
1850        // With min_hops=2, should only return the 2-hop path
1851        let token_a = token(0x01, "A");
1852        let token_b = token(0x02, "B");
1853        let token_c = token(0x03, "C");
1854
1855        let (market, manager) = setup_market_weighted(vec![
1856            ("component_ab", &token_a, &token_b, MockProtocolSim::new(10.0)), /* Direct: 1-hop,
1857                                                                               * high
1858                                                                               * output */
1859            ("component_ac", &token_a, &token_c, MockProtocolSim::new(2.0)), // 2-hop path
1860            ("component_cb", &token_c, &token_b, MockProtocolSim::new(3.0)), // 2-hop path
1861        ]);
1862
1863        // min_hops=2 should skip the 1-hop direct path
1864        let algorithm = MostLiquidAlgorithm::with_config(
1865            AlgorithmConfig::new(2, 3, Duration::from_millis(100), None).unwrap(),
1866        )
1867        .unwrap();
1868        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1869
1870        // Set up derived data with token prices so gas can be deducted
1871        // This ensures shorter paths are preferred due to lower gas cost
1872        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1873
1874        let result = algorithm
1875            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1876            .await
1877            .unwrap();
1878
1879        // Should use 2-hop path (A->C->B), not the direct 1-hop path
1880        assert_eq!(result.route().swaps().len(), 2, "Should use 2-hop path due to min_hops=2");
1881        assert_eq!(result.route().swaps()[0].component_id(), "component_ac");
1882        assert_eq!(result.route().swaps()[1].component_id(), "component_cb");
1883    }
1884
1885    #[tokio::test]
1886    async fn test_find_best_route_respects_max_hops() {
1887        // Setup: Only path is A->B->C (2 hops)
1888        // With max_hops=1, no sequence reaches C, so nothing can be scored
1889        let token_a = token(0x01, "A");
1890        let token_b = token(0x02, "B");
1891        let token_c = token(0x03, "C");
1892
1893        let (market, manager) = setup_market_weighted(vec![
1894            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1895            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1896        ]);
1897
1898        // max_hops=1 cannot reach C from A (needs 2 hops)
1899        let algorithm = MostLiquidAlgorithm::with_config(
1900            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1901        )
1902        .unwrap();
1903        let order = order(&token_a, &token_c, 100, OrderSide::Sell);
1904
1905        let result = algorithm
1906            .find_best_route(manager.graph(), market, None, None, &order)
1907            .await;
1908        assert!(
1909            matches!(result, Err(AlgorithmError::NoPath { reason: NoPathReason::NoGraphPath, .. })),
1910            "Should fail when max_hops is insufficient"
1911        );
1912    }
1913
1914    #[tokio::test]
1915    async fn test_find_best_route_timeout_returns_best_so_far() {
1916        // Setup: Many parallel paths to process
1917        // With very short timeout, should return the best route found before timeout
1918        // or Timeout error if no route was completed
1919        let token_a = token(0x01, "A");
1920        let token_b = token(0x02, "B");
1921
1922        // Create many parallel components to ensure multiple paths need processing
1923        let (market, manager) = setup_market_weighted(vec![
1924            ("component1", &token_a, &token_b, MockProtocolSim::new(1.0)),
1925            ("component2", &token_a, &token_b, MockProtocolSim::new(2.0)),
1926            ("component3", &token_a, &token_b, MockProtocolSim::new(3.0)),
1927            ("component4", &token_a, &token_b, MockProtocolSim::new(4.0)),
1928            ("component5", &token_a, &token_b, MockProtocolSim::new(5.0)),
1929        ]);
1930
1931        // timeout=0ms should timeout after processing some paths
1932        let algorithm = MostLiquidAlgorithm::with_config(
1933            AlgorithmConfig::new(1, 1, Duration::from_millis(0), None).unwrap(),
1934        )
1935        .unwrap();
1936        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1937
1938        let result = algorithm
1939            .find_best_route(manager.graph(), market, None, None, &order)
1940            .await;
1941
1942        // With 0ms timeout, we either get:
1943        // - A route (if at least one path completed before timeout check)
1944        // - Timeout error (if no path completed)
1945        // Both are valid outcomes - the key is we don't hang
1946        match result {
1947            Ok(r) => {
1948                // If we got a route, verify it's valid
1949                assert_eq!(r.route().swaps().len(), 1);
1950            }
1951            Err(AlgorithmError::Timeout { .. }) => {
1952                // Timeout is also acceptable
1953            }
1954            Err(e) => panic!("Unexpected error: {:?}", e),
1955        }
1956    }
1957
1958    // ==================== Algorithm Trait Getter Tests ====================
1959
1960    #[rstest::rstest]
1961    #[case::default_config(1, 3, 50)]
1962    #[case::single_hop_only(1, 1, 100)]
1963    #[case::multi_hop_min(2, 5, 200)]
1964    #[case::zero_timeout(1, 3, 0)]
1965    #[case::large_values(10, 100, 10000)]
1966    fn test_algorithm_config_getters(
1967        #[case] min_hops: usize,
1968        #[case] max_hops: usize,
1969        #[case] timeout_ms: u64,
1970    ) {
1971        use crate::algorithm::Algorithm;
1972
1973        let algorithm = MostLiquidAlgorithm::with_config(
1974            AlgorithmConfig::new(min_hops, max_hops, Duration::from_millis(timeout_ms), None)
1975                .unwrap(),
1976        )
1977        .unwrap();
1978
1979        assert_eq!(algorithm.query.max_hops, max_hops);
1980        assert_eq!(algorithm.timeout, Duration::from_millis(timeout_ms));
1981        assert_eq!(algorithm.name(), "most_liquid");
1982    }
1983
1984    #[test]
1985    fn test_algorithm_default_config() {
1986        use crate::algorithm::Algorithm;
1987
1988        let algorithm = MostLiquidAlgorithm::new();
1989
1990        assert_eq!(algorithm.query.max_hops, 3);
1991        assert_eq!(algorithm.timeout, Duration::from_millis(500));
1992        assert_eq!(algorithm.name(), "most_liquid");
1993    }
1994
1995    // ==================== Hop solving Tests ====================
1996
1997    /// Each hop is solved on its own, at the amount that actually reaches it.
1998    ///
1999    /// The pool paying most is deliberately not the first on either hop, and it is not the one the
2000    /// score ranking favours either — `spot_price * min_depth` prefers the deep, cheap pools here.
2001    /// Only simulating at the order's size finds them, and picking per hop is what makes that
2002    /// affordable: four simulations rather than the four two-hop routes they combine into.
2003    #[tokio::test]
2004    async fn test_each_leg_takes_its_best_paying_pool() {
2005        let token_a = token(0x01, "A");
2006        let token_b = token(0x02, "B");
2007        let token_c = token(0x03, "C");
2008
2009        // Ids sort ascending, so the weak pool of each pair is reached first.
2010        let (market, manager) = setup_market_weighted(vec![
2011            ("ab1", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(9_000_000)),
2012            ("ab2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(1_000_000)),
2013            ("bc1", &token_b, &token_c, MockProtocolSim::new(1.0).with_liquidity(9_000_000)),
2014            ("bc2", &token_b, &token_c, MockProtocolSim::new(5.0).with_liquidity(1_000_000)),
2015        ]);
2016
2017        let algorithm = MostLiquidAlgorithm::with_config(
2018            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
2019        )
2020        .unwrap();
2021        let order = order(&token_a, &token_c, 1000, OrderSide::Sell);
2022        let result = algorithm
2023            .find_best_route(manager.graph(), market, None, None, &order)
2024            .await
2025            .unwrap();
2026
2027        let chosen: Vec<&str> = result
2028            .route()
2029            .swaps()
2030            .iter()
2031            .map(|swap| swap.component_id())
2032            .collect();
2033        assert_eq!(chosen, vec!["ab2", "bc2"], "each hop must take its best-paying pool");
2034
2035        // 1000 -> 3000 over the first hop, and the second hop has to be settled on that 3000
2036        // rather than on the order amount.
2037        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(3000u64));
2038        assert_eq!(*result.route().swaps()[1].amount_in(), BigUint::from(3000u64));
2039        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(15000u64));
2040    }
2041
2042    /// `max_routes` bounds token sequences, not the pools on a pair.
2043    ///
2044    /// Every pool on a pair is compared at the amount actually flowing, whatever the cap, so the
2045    /// pool paying four times the input is found even at `max_routes = 2` -- where the score
2046    /// ranking, which never sees the order size, puts it last.
2047    #[tokio::test]
2048    async fn test_max_routes_caps_token_sequences_not_pools_on_a_pair() {
2049        // 4 parallel components. Score = spot_price * min_depth.
2050        // In tests, depth comes from get_limits().0 (sell_limit), which is
2051        // liquidity / (spot_price * (1 - fee)). With fee=0: depth = liquidity / spot_price.
2052        // We vary liquidity to create a clear score ranking:
2053        //   component4 (score = 1.0 * 4M/1.0 = 4M)
2054        //   component3 (score = 2.0 * 3M/2.0 = 3M)
2055        //   component2 (score = 3.0 * 2M/3.0 = 2M)
2056        //   component1 (score = 4.0 * 1M/4.0 = 1M)
2057        let token_a = token(0x01, "A");
2058        let token_b = token(0x02, "B");
2059
2060        let (market, manager) = setup_market_weighted(vec![
2061            ("component1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
2062            ("component2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
2063            ("component3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
2064            ("component4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
2065        ]);
2066
2067        let algorithm = MostLiquidAlgorithm::with_config(
2068            AlgorithmConfig::new(1, 1, Duration::from_millis(100), Some(2)).unwrap(),
2069        )
2070        .unwrap();
2071        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
2072        let result = algorithm
2073            .find_best_route(manager.graph(), market, None, None, &order)
2074            .await
2075            .unwrap();
2076
2077        // A -> B is one sequence however many pools serve it, so the cap has nothing to remove and
2078        // every pool is compared at the order's own size. component1 pays the most.
2079        assert_eq!(result.route().swaps().len(), 1);
2080        assert_eq!(result.route().swaps()[0].component_id(), "component1");
2081        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(4000u64));
2082    }
2083
2084    #[tokio::test]
2085    async fn test_find_best_route_no_cap_when_max_routes_is_none() {
2086        // Same setup but no cap — component1 (best output) should win.
2087        let token_a = token(0x01, "A");
2088        let token_b = token(0x02, "B");
2089
2090        let (market, manager) = setup_market_weighted(vec![
2091            ("component1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
2092            ("component2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
2093            ("component3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
2094            ("component4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
2095        ]);
2096
2097        let algorithm = MostLiquidAlgorithm::with_config(
2098            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
2099        )
2100        .unwrap();
2101        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
2102        let result = algorithm
2103            .find_best_route(manager.graph(), market, None, None, &order)
2104            .await
2105            .unwrap();
2106
2107        // All 4 paths simulated, component1 wins with best output (4x)
2108        assert_eq!(result.route().swaps().len(), 1);
2109        assert_eq!(result.route().swaps()[0].component_id(), "component1");
2110        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(4000u64));
2111    }
2112
2113    // ==================== Configuration Validation Tests ====================
2114
2115    #[test]
2116    fn test_algorithm_config_rejects_zero_max_routes() {
2117        let result = AlgorithmConfig::new(1, 3, Duration::from_millis(100), Some(0));
2118        assert!(matches!(
2119            result,
2120            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("max_routes must be at least 1")
2121        ));
2122    }
2123
2124    #[test]
2125    fn test_algorithm_config_rejects_zero_min_hops() {
2126        let result = AlgorithmConfig::new(0, 3, Duration::from_millis(100), None);
2127        assert!(matches!(
2128            result,
2129            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("min_hops must be at least 1")
2130        ));
2131    }
2132
2133    #[test]
2134    fn test_algorithm_config_rejects_min_greater_than_max() {
2135        let result = AlgorithmConfig::new(5, 3, Duration::from_millis(100), None);
2136        assert!(matches!(
2137            result,
2138            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("cannot exceed")
2139        ));
2140    }
2141
2142    // ==================== score_token_path Tests ====================
2143
2144    /// A hop with no measured pool cannot be placed, so the sequence sinks to the bottom of the
2145    /// queue rather than out of it: it still scores, and the score is the lowest there is.
2146    #[test]
2147    fn test_score_token_path_sinks_a_sequence_with_an_unmeasured_hop() {
2148        let (a, b, c, _) = addrs();
2149        let mut manager = linear_graph();
2150        // A->B measured, B->C left without derived data.
2151        manager
2152            .set_pool_weight(&"ab".to_string(), &a, &b, DepthAndPrice::new(2.0, 1000.0), false)
2153            .unwrap();
2154        let graph = manager.graph();
2155        let node = |address: &Address| graph.get_token_ix(address).unwrap();
2156
2157        let unmeasured =
2158            MostLiquidAlgorithm::score_token_path(graph, &[node(&a), node(&b), node(&c)]);
2159
2160        assert_eq!(unmeasured, Some(0.0), "an unmeasured hop scores zero, not None");
2161        assert!(
2162            MostLiquidAlgorithm::score_token_path(graph, &[node(&a), node(&b)])
2163                .is_some_and(|measured| measured > 0.0),
2164            "a fully measured sequence still outranks it"
2165        );
2166    }
2167
2168    /// A sequence naming a pair the graph has no pool for is the two indexes disagreeing, not a
2169    /// routing outcome, so it is dropped rather than ranked.
2170    #[test]
2171    fn test_score_token_path_drops_a_sequence_with_an_unconnected_pair() {
2172        let (a, _, c, _) = addrs();
2173        let manager = linear_graph();
2174        let graph = manager.graph();
2175        let node = |address: &Address| graph.get_token_ix(address).unwrap();
2176
2177        assert_eq!(MostLiquidAlgorithm::score_token_path(graph, &[node(&a), node(&c)]), None);
2178        assert_eq!(MostLiquidAlgorithm::score_token_path(graph, &[node(&a)]), None);
2179    }
2180
2181    // ==================== PairWinners Tests ====================
2182
2183    /// `count` pools on one pair, named `pool0`, `pool1`, ... for [`simulator`] to answer as.
2184    fn pools(count: usize) -> Vec<EdgeData<()>> {
2185        (0..count)
2186            .map(|i| EdgeData::new(format!("pool{i}")))
2187            .collect()
2188    }
2189
2190    /// Answers as pool `i` would: out is `amount_in * multipliers[i]`, gas is flat.
2191    fn simulator(
2192        multipliers: [u64; 2],
2193        amount_in: u64,
2194    ) -> impl FnMut(&ComponentId) -> Option<(SwapResult, BigInt)> {
2195        move |component_id: &ComponentId| {
2196            let index: usize = component_id
2197                .trim_start_matches("pool")
2198                .parse()
2199                .ok()?;
2200            let amount = BigUint::from(amount_in * multipliers[index]);
2201            let paid = SwapResult { amount_out: amount.clone(), gas: BigUint::from(10u64) };
2202            Some((paid, BigInt::from(amount)))
2203        }
2204    }
2205
2206    /// The one token pair every case works on.
2207    fn pair() -> (NodeIndex, NodeIndex) {
2208        (NodeIndex::new(0), NodeIndex::new(1))
2209    }
2210
2211    #[test]
2212    fn test_winners_takes_the_pool_that_pays_most() {
2213        let mut winners = PairWinners::new(true);
2214        let multipliers = [2u64, 5u64];
2215        let pools = pools(multipliers.len());
2216
2217        let outcome = winners
2218            .hop(pair(), &pools, simulator(multipliers, 100))
2219            .unwrap();
2220
2221        assert_eq!(outcome.pool_ix, 1, "pool1 pays 500 against pool0's 200");
2222        assert_eq!(outcome.amount_out, BigUint::from(500u64));
2223    }
2224
2225    /// Once a pair has a winner, only that pool is asked. Whether the ask reaches the pool or is
2226    /// answered from an amount already asked is the swap cache's business, not this one's.
2227    #[test]
2228    fn test_winners_asks_only_the_remembered_winner() {
2229        let mut winners = PairWinners::new(true);
2230        let multipliers = [2u64, 5u64];
2231        let pools = pools(multipliers.len());
2232        winners
2233            .hop(pair(), &pools, simulator(multipliers, 100))
2234            .unwrap();
2235
2236        let mut asked = Vec::new();
2237        let outcome = winners
2238            .hop(pair(), &pools, |component_id: &ComponentId| {
2239                asked.push(component_id.clone());
2240                simulator(multipliers, 100)(component_id)
2241            })
2242            .unwrap();
2243
2244        assert_eq!(asked, vec!["pool1".to_string()], "only the winner should be asked");
2245        assert_eq!(outcome.pool_ix, 1);
2246    }
2247
2248    /// A winner that cannot trade the amount does not end the hop: every pool is asked, and the
2249    /// one that answers becomes the pair's winner.
2250    #[test]
2251    fn test_winners_falls_back_to_every_pool_when_the_winner_cannot_trade() {
2252        let mut winners = PairWinners::new(true);
2253        let pools = pools(2);
2254        winners
2255            .hop(pair(), &pools, simulator([2, 5], 100))
2256            .unwrap();
2257
2258        let outcome = winners
2259            .hop(pair(), &pools, |component_id: &ComponentId| {
2260                (component_id == "pool0").then(|| {
2261                    let amount = BigUint::from(50u64);
2262                    (
2263                        SwapResult { amount_out: amount.clone(), gas: BigUint::from(10u64) },
2264                        BigInt::from(amount),
2265                    )
2266                })
2267            })
2268            .unwrap();
2269
2270        assert_eq!(outcome.pool_ix, 0, "pool1 refused, so pool0 takes the pair");
2271        assert_eq!(outcome.amount_out, BigUint::from(50u64));
2272    }
2273
2274    /// Off, no winner is remembered, so every pool is asked every time.
2275    #[test]
2276    fn test_winners_disabled_scans_every_pool_each_time() {
2277        let mut winners = PairWinners::new(false);
2278        let multipliers = [2u64, 5u64];
2279        let pools = pools(multipliers.len());
2280
2281        let first = winners
2282            .hop(pair(), &pools, simulator(multipliers, 100))
2283            .unwrap();
2284        let mut asked = 0usize;
2285        let second = winners
2286            .hop(pair(), &pools, |component_id: &ComponentId| {
2287                asked += 1;
2288                simulator(multipliers, 100)(component_id)
2289            })
2290            .unwrap();
2291
2292        assert_eq!(asked, 2, "both pools are asked again, so no winner was remembered");
2293        assert_eq!(first.amount_out, second.amount_out);
2294    }
2295
2296    /// No pool on the pair can trade the amount.
2297    #[test]
2298    fn test_winners_returns_none_when_no_pool_trades() {
2299        let mut winners = PairWinners::new(true);
2300        let pools = pools(2);
2301
2302        let outcome = winners.hop(pair(), &pools, |_| None);
2303
2304        assert!(outcome.is_none());
2305    }
2306}