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