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 all edge paths up to max_hops using BFS (shorter paths first, all parallel edges)
5//! 2. Scoring and sorting paths by spot price, fees, and liquidity depth
6//! 3. Simulating paths with actual ProtocolSim to get accurate output (best paths first)
7//! 4. Ranking by net output (output - gas cost in output token terms)
8//! 5. Returning the best route with stats recorded to the tracing span
9
10use std::{
11    collections::{HashMap, HashSet, VecDeque},
12    time::{Duration, Instant},
13};
14
15use metrics::{counter, histogram};
16use num_bigint::{BigInt, BigUint};
17use num_traits::ToPrimitive;
18use petgraph::prelude::EdgeRef;
19use tracing::{debug, instrument, trace};
20use tycho_simulation::{
21    tycho_common::simulation::protocol_sim::ProtocolSim,
22    tycho_core::models::{token::Token, Address},
23};
24
25use super::{Algorithm, AlgorithmConfig, NoPathReason};
26use crate::{
27    derived::{computation::ComputationRequirements, types::TokenGasPrices, SharedDerivedDataRef},
28    feed::market_data::{MarketData, MarketState, StateLabel},
29    graph::{petgraph::StableDiGraph, Path, PetgraphStableDiGraphManager},
30    types::{ComponentId, Order, Route, RouteResult, Swap},
31    AlgorithmError,
32};
33/// Algorithm that selects routes based on expected output after gas.
34pub struct MostLiquidAlgorithm {
35    min_hops: usize,
36    max_hops: usize,
37    timeout: Duration,
38    max_routes: Option<usize>,
39    connector_tokens: Option<HashSet<Address>>,
40}
41
42/// Algorithm-specific edge data for liquidity-based routing.
43///
44/// Used by the MostLiquid algorithm to score paths based on expected output.
45/// Contains the spot price and liquidity depth.
46/// Note that the fee is included in the spot price already.
47#[derive(Debug, Clone, Default)]
48pub struct DepthAndPrice {
49    /// Spot price (token_out per token_in) for this edge direction.
50    pub spot_price: f64,
51    /// Liquidity depth normalized to gas token (native token) units.
52    pub depth: f64,
53}
54
55impl DepthAndPrice {
56    /// Creates a new DepthAndPrice with all fields set.
57    #[cfg(test)]
58    pub fn new(spot_price: f64, depth: f64) -> Self {
59        Self { spot_price, depth }
60    }
61
62    /// Compute depth and spot price from a live protocol simulation.
63    #[cfg(test)]
64    pub fn from_protocol_sim(
65        sim: &impl ProtocolSim,
66        token_in: &Token,
67        token_out: &Token,
68    ) -> Result<Self, AlgorithmError> {
69        Ok(Self {
70            spot_price: sim
71                .spot_price(token_in, token_out)
72                .map_err(|e| {
73                    AlgorithmError::Other(format!("missing spot price for DepthAndPrice: {:?}", e))
74                })?,
75            depth: sim
76                .get_limits(token_in.address.clone(), token_out.address.clone())
77                .map_err(|e| {
78                    AlgorithmError::Other(format!("missing depth for DepthAndPrice: {:?}", e))
79                })?
80                .0
81                .to_f64()
82                .ok_or_else(|| {
83                    AlgorithmError::Other("depth conversion to f64 failed".to_string())
84                })?,
85        })
86    }
87}
88
89impl crate::graph::EdgeWeightFromSimAndDerived for DepthAndPrice {
90    fn from_sim_and_derived(
91        _sim: &dyn ProtocolSim,
92        component_id: &ComponentId,
93        token_in: &Token,
94        token_out: &Token,
95        derived: &crate::derived::DerivedData,
96    ) -> Option<Self> {
97        let key = (component_id.clone(), token_in.address.clone(), token_out.address.clone());
98
99        // Use pre-computed spot price; skip edge if unavailable.
100        let spot_price = match derived
101            .spot_prices()
102            .and_then(|p| p.get(&key).copied())
103        {
104            Some(p) => p,
105            None => {
106                trace!(component_id = %component_id, "spot price not found, skipping edge");
107                return None;
108            }
109        };
110
111        // Look up pre-computed depth; skip edge if unavailable.
112        let raw_depth = match derived
113            .pool_depths()
114            .and_then(|d| d.get(&key))
115        {
116            Some(d) => d.to_f64().unwrap_or(0.0),
117            None => {
118                trace!(component_id = %component_id, "pool depth not found, skipping edge");
119                return None;
120            }
121        };
122
123        // Normalize depth from raw token_in units to gas token units.
124        // TokenGasPrices stores Price { numerator, denominator } where
125        // numerator/denominator = "token units per gas token unit".
126        // To convert to gas token: depth_gas = raw_depth * denominator / numerator.
127        let depth = match derived
128            .token_prices()
129            .and_then(|p| p.get(&token_in.address))
130        {
131            Some(price) => {
132                let num = match price.numerator.to_f64() {
133                    Some(v) if v > 0.0 => v,
134                    Some(_) => {
135                        trace!(
136                            component_id = %component_id,
137                            token_in = %token_in.address,
138                            "token price numerator is zero, skipping edge"
139                        );
140                        return None;
141                    }
142                    None => {
143                        trace!(
144                            component_id = %component_id,
145                            token_in = %token_in.address,
146                            "token price numerator overflows f64, skipping edge"
147                        );
148                        return None;
149                    }
150                };
151                let den = match price.denominator.to_f64() {
152                    Some(v) if v > 0.0 => v,
153                    Some(_) => {
154                        trace!(
155                            component_id = %component_id,
156                            token_in = %token_in.address,
157                            "token price denominator is zero, skipping edge"
158                        );
159                        return None;
160                    }
161                    None => {
162                        trace!(
163                            component_id = %component_id,
164                            token_in = %token_in.address,
165                            "token price denominator overflows f64, skipping edge"
166                        );
167                        return None;
168                    }
169                };
170                raw_depth * den / num
171            }
172            None => {
173                trace!(
174                    component_id = %component_id,
175                    token_in = %token_in.address,
176                    "token price not found, skipping edge"
177                );
178                return None;
179            }
180        };
181
182        Some(Self { spot_price, depth })
183    }
184}
185
186impl MostLiquidAlgorithm {
187    /// Creates a new MostLiquidAlgorithm with default settings.
188    pub fn new() -> Self {
189        Self {
190            min_hops: 1,
191            max_hops: 3,
192            timeout: Duration::from_millis(500),
193            max_routes: None,
194            connector_tokens: None,
195        }
196    }
197
198    /// Creates a new MostLiquidAlgorithm with custom settings.
199    pub fn with_config(config: AlgorithmConfig) -> Result<Self, AlgorithmError> {
200        Ok(Self {
201            min_hops: config.min_hops(),
202            max_hops: config.max_hops(),
203            timeout: config.timeout(),
204            max_routes: config.max_routes(),
205            connector_tokens: config.connector_tokens().cloned(),
206        })
207    }
208
209    /// Finds all paths between two tokens using BFS directly on the graph.
210    ///
211    /// This is a helper method that operates on the graph without needing the graph manager.
212    /// It performs BFS traversal to find all paths within the hop budget.
213    ///
214    /// # Errors
215    ///
216    /// Returns `AlgorithmError` if:
217    /// - Source token is not in the graph
218    /// - Destination token is not in the graph
219    #[instrument(level = "debug", skip(graph, connector_tokens))]
220    fn find_paths<'a>(
221        graph: &'a StableDiGraph<DepthAndPrice>,
222        from: &Address,
223        to: &Address,
224        min_hops: usize,
225        max_hops: usize,
226        connector_tokens: Option<&HashSet<Address>>,
227    ) -> Result<Vec<Path<'a, DepthAndPrice>>, AlgorithmError> {
228        if min_hops == 0 || min_hops > max_hops {
229            return Err(AlgorithmError::InvalidConfiguration {
230                reason: format!(
231                    "invalid hop configuration: min_hops={min_hops} max_hops={max_hops}",
232                ),
233            });
234        }
235
236        // Find source and destination nodes by address
237        // TODO: this could be optimized by using a node index map in the graph manager
238        let from_idx = graph
239            .node_indices()
240            .find(|&n| &graph[n] == from)
241            .ok_or(AlgorithmError::NoPath {
242                from: from.clone(),
243                to: to.clone(),
244                reason: NoPathReason::SourceTokenNotInGraph,
245            })?;
246        let to_idx = graph
247            .node_indices()
248            .find(|&n| &graph[n] == to)
249            .ok_or(AlgorithmError::NoPath {
250                from: from.clone(),
251                to: to.clone(),
252                reason: NoPathReason::DestinationTokenNotInGraph,
253            })?;
254
255        let mut paths = Vec::new();
256        let mut queue = VecDeque::new();
257        queue.push_back((from_idx, Path::new()));
258
259        while let Some((current_node, current_path)) = queue.pop_front() {
260            if current_path.len() >= max_hops {
261                continue;
262            }
263
264            for edge in graph.edges(current_node) {
265                let next_node = edge.target();
266                let next_addr = &graph[next_node];
267
268                // Skip paths that revisit a token already in the path.
269                // Exception: when source == destination, the destination may appear at the end
270                // (forming a first == last cycle, e.g. USDC → WETH → USDC). All other intermediate
271                // cycles (e.g. USDC → WETH → WBTC → WETH) are not supported by Tycho execution.
272                let already_visited = current_path.tokens.contains(&next_addr);
273                let is_closing_circular_route = from_idx == to_idx && next_node == to_idx;
274                if already_visited && !is_closing_circular_route {
275                    continue;
276                }
277
278                // Skip disallowed connector tokens. Endpoints (from / to) are always permitted.
279                let is_destination = next_node == to_idx;
280                if !is_destination {
281                    if let Some(tokens) = connector_tokens {
282                        if !tokens.contains(next_addr) {
283                            continue;
284                        }
285                    }
286                }
287
288                let mut new_path = current_path.clone();
289                new_path.add_hop(&graph[current_node], edge.weight(), next_addr);
290
291                if next_node == to_idx && new_path.len() >= min_hops {
292                    paths.push(new_path.clone());
293                }
294
295                queue.push_back((next_node, new_path));
296            }
297        }
298
299        Ok(paths)
300    }
301
302    /// Attempts to score a path based on spot prices and minimum liquidity depth.
303    ///
304    /// Formula: `score = (product of all spot_price) × min(depths)`
305    ///
306    /// This accounts for:
307    /// - Spot price: the theoretical exchange rate along the path not accounting for slippage
308    /// - Fees: included in spot_price already
309    /// - Depth (inertia): minimum depth acts as a liquidity bottleneck indicator
310    ///
311    /// Returns `None` if the path cannot be scored (empty path or missing edge weights).
312    /// Paths that return `None` are filtered out of simulation.
313    ///
314    /// Higher score = better path candidate. Paths through deeper pools rank higher.
315    fn try_score_path(path: &Path<DepthAndPrice>) -> Option<f64> {
316        if path.is_empty() {
317            trace!("cannot score empty path");
318            return None;
319        }
320
321        let mut price = 1.0;
322        let mut min_depth = f64::MAX;
323
324        for edge in path.edge_iter() {
325            let Some(data) = edge.data.as_ref() else {
326                debug!(component_id = %edge.component_id, "edge missing weight data, path cannot be scored");
327                return None;
328            };
329
330            price *= data.spot_price;
331            min_depth = min_depth.min(data.depth);
332        }
333
334        Some(price * min_depth)
335    }
336
337    /// Simulates swaps along a path using each pool's `ProtocolSim::get_amount_out`.
338    /// Tracks intermediate state changes to handle routes that revisit the same pool.
339    ///
340    /// Calculates `net_amount_out` by subtracting gas cost from the output amount.
341    /// The result can be negative if gas cost exceeds output (e.g., inaccurate gas estimation).
342    ///
343    /// # Arguments
344    /// * `path` - The edge path to simulate
345    /// * `graph` - The graph containing edge and node data
346    /// * `market` - Market data for token/component lookups and gas price
347    /// * `token_prices` - Optional token prices for gas cost conversion
348    /// * `amount_in` - The input amount to simulate
349    #[instrument(level = "trace", skip(path, market, token_prices), fields(hop_count = path.len()))]
350    pub(crate) fn simulate_path<D>(
351        path: &Path<D>,
352        market: &MarketState,
353        token_prices: Option<&TokenGasPrices>,
354        amount_in: BigUint,
355    ) -> Result<RouteResult, AlgorithmError> {
356        let mut current_amount = amount_in.clone();
357        let mut swaps = Vec::with_capacity(path.len());
358
359        // Track state overrides for pools we've already swapped through.
360        let mut state_overrides: HashMap<&ComponentId, Box<dyn ProtocolSim>> = HashMap::new();
361
362        for (address_in, edge_data, address_out) in path.iter() {
363            // Get token and component data for the simulation call
364            let token_in = market
365                .get_token(address_in)
366                .ok_or_else(|| AlgorithmError::DataNotFound {
367                    kind: "token",
368                    id: Some(format!("{:?}", address_in)),
369                })?;
370            let token_out = market
371                .get_token(address_out)
372                .ok_or_else(|| AlgorithmError::DataNotFound {
373                    kind: "token",
374                    id: Some(format!("{:?}", address_out)),
375                })?;
376
377            let component_id = &edge_data.component_id;
378            let component = market
379                .get_component(component_id)
380                .ok_or_else(|| AlgorithmError::DataNotFound {
381                    kind: "component",
382                    id: Some(component_id.clone()),
383                })?;
384            let component_state = market
385                .get_simulation_state(component_id)
386                .ok_or_else(|| AlgorithmError::DataNotFound {
387                    kind: "simulation state",
388                    id: Some(component_id.clone()),
389                })?;
390
391            let state = state_overrides
392                .get(component_id)
393                .map(Box::as_ref)
394                .unwrap_or(component_state);
395
396            // Simulate the swap
397            let result = state
398                .get_amount_out(current_amount.clone(), token_in, token_out)
399                .map_err(|e| AlgorithmError::Other(format!("simulation error: {:?}", e)))?;
400
401            // Record the swap
402            swaps.push(Swap::new(
403                component_id.clone(),
404                component.protocol_system.clone(),
405                token_in.address.clone(),
406                token_out.address.clone(),
407                current_amount.clone(),
408                result.amount.clone(),
409                result.gas,
410                component.clone(),
411                state.clone_box(),
412            ));
413
414            state_overrides.insert(component_id, result.new_state);
415            current_amount = result.amount;
416        }
417
418        // Calculate net amount out (output - gas cost in output token terms)
419        let route = Route::new(swaps);
420        let output_amount = route
421            .swaps()
422            .last()
423            .map(|s| s.amount_out().clone())
424            .unwrap_or_else(|| BigUint::ZERO);
425
426        let gas_price = market
427            .gas_price()
428            .ok_or(AlgorithmError::DataNotFound { kind: "gas price", id: None })?
429            .effective_gas_price()
430            .clone();
431
432        let net_amount_out = if let Some(last_swap) = route.swaps().last() {
433            let total_gas = route.total_gas();
434            let gas_cost_wei = &total_gas * &gas_price;
435
436            // Convert gas cost to output token terms using token prices
437            let gas_cost_in_output_token: Option<BigUint> = token_prices
438                .and_then(|prices| prices.get(last_swap.token_out()))
439                .map(|price| {
440                    // gas_cost_in_token = gas_cost_wei * numerator / denominator
441                    // where numerator = tokens per ETH, denominator = 10^18 + path_gas
442                    &gas_cost_wei * &price.numerator / &price.denominator
443                });
444
445            match gas_cost_in_output_token {
446                Some(gas_cost) => BigInt::from(output_amount) - BigInt::from(gas_cost),
447                None => {
448                    // No token price available - use output amount as-is
449                    // This happens if derived data hasn't been computed yet
450                    BigInt::from(output_amount)
451                }
452            }
453        } else {
454            BigInt::from(output_amount)
455        };
456
457        Ok(RouteResult::new(route, net_amount_out, gas_price))
458    }
459}
460
461impl Default for MostLiquidAlgorithm {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467impl Algorithm for MostLiquidAlgorithm {
468    type GraphType = StableDiGraph<DepthAndPrice>;
469    type GraphManager = PetgraphStableDiGraphManager<DepthAndPrice>;
470
471    fn name(&self) -> &str {
472        "most_liquid"
473    }
474
475    // TODO: Consider adding token pair symbols to the span for easier interpretation
476    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
477    async fn find_best_route(
478        &self,
479        graph: &Self::GraphType,
480        market: MarketData,
481        label: Option<StateLabel>,
482        derived: Option<SharedDerivedDataRef>,
483        order: &Order,
484    ) -> Result<RouteResult, AlgorithmError> {
485        let start = Instant::now();
486
487        // Exact-out isn't supported yet
488        if !order.is_sell() {
489            return Err(AlgorithmError::ExactOutNotSupported);
490        }
491
492        // Extract token prices from derived data (if available)
493        let token_prices = if let Some(ref derived) = derived {
494            derived
495                .read()
496                .await
497                .token_prices()
498                .cloned()
499        } else {
500            None
501        };
502
503        let amount_in = order.amount().clone();
504
505        // Step 1: Find all edge paths using BFS (shorter paths first)
506        let all_paths = Self::find_paths(
507            graph,
508            order.token_in(),
509            order.token_out(),
510            self.min_hops,
511            self.max_hops,
512            self.connector_tokens.as_ref(),
513        )?;
514
515        let paths_candidates = all_paths.len();
516        if paths_candidates == 0 {
517            return Err(AlgorithmError::NoPath {
518                from: order.token_in().clone(),
519                to: order.token_out().clone(),
520                reason: NoPathReason::NoGraphPath,
521            });
522        }
523
524        // Step 2: Score and sort all paths by estimated output (higher score = better)
525        // No lock needed — scoring uses only local graph data.
526        let mut scored_paths: Vec<(Path<DepthAndPrice>, f64)> = all_paths
527            .into_iter()
528            .filter_map(|path| {
529                let score = Self::try_score_path(&path)?;
530                Some((path, score))
531            })
532            .collect();
533
534        scored_paths.sort_by(|(_, a_score), (_, b_score)| {
535            // Flip the comparison to get descending order
536            b_score
537                .partial_cmp(a_score)
538                .unwrap_or(std::cmp::Ordering::Equal)
539        });
540
541        if let Some(max_routes) = self.max_routes {
542            scored_paths.truncate(max_routes);
543        }
544
545        let paths_to_simulate = scored_paths.len();
546        let scoring_failures = paths_candidates - paths_to_simulate;
547        if paths_to_simulate == 0 {
548            return Err(AlgorithmError::NoPath {
549                from: order.token_in().clone(),
550                to: order.token_out().clone(),
551                reason: NoPathReason::NoScorablePaths,
552            });
553        }
554
555        // Step 3: Extract component IDs from all paths we'll simulate
556        let component_ids: HashSet<ComponentId> = scored_paths
557            .iter()
558            .flat_map(|(path, _)| {
559                path.edge_iter()
560                    .iter()
561                    .map(|e| e.component_id.clone())
562            })
563            .collect();
564
565        // Step 4: Brief lock — check gas price + extract market subset for simulation
566        let market = {
567            let market = match label.as_ref() {
568                Some(l) => market
569                    .read_labeled(l)
570                    .await
571                    .map_err(|e| AlgorithmError::Other(e.to_string()))?,
572                None => market.read().await,
573            };
574            if market.gas_price().is_none() {
575                return Err(AlgorithmError::DataNotFound { kind: "gas price", id: None });
576            }
577            let market_subset = market.extract_subset_with_overlay(&component_ids);
578            drop(market);
579            market_subset
580        };
581
582        let mut paths_simulated = 0usize;
583        let mut simulation_failures = 0usize;
584
585        // Step 5: Simulate all paths in score order using the local market subset
586        let mut best: Option<RouteResult> = None;
587        let timeout_ms = self.timeout.as_millis() as u64;
588
589        for (edge_path, _) in scored_paths {
590            // Check timeout
591            let elapsed_ms = start.elapsed().as_millis() as u64;
592            if elapsed_ms > timeout_ms {
593                break;
594            }
595
596            let result = match Self::simulate_path(
597                &edge_path,
598                &market,
599                token_prices.as_ref(),
600                amount_in.clone(),
601            ) {
602                Ok(r) => r,
603                Err(e) => {
604                    trace!(error = %e, "simulation failed for path");
605                    simulation_failures += 1;
606                    continue;
607                }
608            };
609
610            // Check if this is the best result so far
611            if best
612                .as_ref()
613                .map(|best| result.net_amount_out() > best.net_amount_out())
614                .unwrap_or(true)
615            {
616                best = Some(result);
617            }
618
619            paths_simulated += 1;
620        }
621
622        // Log solve result
623        let solve_time_ms = start.elapsed().as_millis() as u64;
624        let block_number = market
625            .last_updated()
626            .map(|b| b.number());
627        // The proportion of paths simulated to total paths that we filtered to simulate
628        let coverage_pct = if paths_to_simulate == 0 {
629            100.0
630        } else {
631            (paths_simulated as f64 / paths_to_simulate as f64) * 100.0
632        };
633
634        // Record metrics
635        counter!("algorithm.scoring_failures").increment(scoring_failures as u64);
636        counter!("algorithm.simulation_failures").increment(simulation_failures as u64);
637        histogram!("algorithm.simulation_coverage_pct").record(coverage_pct);
638
639        match &best {
640            Some(result) => {
641                let tokens = market.token_registry_ref();
642                let path_desc = result.route().path_description(tokens);
643                let protocols = result
644                    .route()
645                    .swaps()
646                    .iter()
647                    .map(|s| s.protocol())
648                    .collect::<Vec<_>>();
649
650                let price = amount_in
651                    .to_f64()
652                    .filter(|&v| v > 0.0)
653                    .and_then(|amt_in| {
654                        result
655                            .net_amount_out()
656                            .to_f64()
657                            .map(|amt_out| amt_out / amt_in)
658                    })
659                    .unwrap_or(f64::NAN);
660
661                debug!(
662                    solve_time_ms,
663                    block_number,
664                    paths_candidates,
665                    paths_to_simulate,
666                    paths_simulated,
667                    simulation_failures,
668                    simulation_coverage_pct = coverage_pct,
669                    components_considered = component_ids.len(),
670                    tokens_considered = market.token_registry_ref().len(),
671                    path = %path_desc,
672                    amount_in = %amount_in,
673                    net_amount_out = %result.net_amount_out(),
674                    price_out_per_in = price,
675                    hop_count = result.route().swaps().len(),
676                    protocols = ?protocols,
677                    "route found"
678                );
679            }
680            None => {
681                debug!(
682                    solve_time_ms,
683                    block_number,
684                    paths_candidates,
685                    paths_to_simulate,
686                    paths_simulated,
687                    simulation_failures,
688                    simulation_coverage_pct = coverage_pct,
689                    components_considered = component_ids.len(),
690                    tokens_considered = market.token_registry_ref().len(),
691                    "no viable route"
692                );
693            }
694        }
695
696        best.ok_or({
697            if solve_time_ms > timeout_ms {
698                AlgorithmError::Timeout { elapsed_ms: solve_time_ms }
699            } else {
700                AlgorithmError::InsufficientLiquidity
701            }
702        })
703    }
704
705    fn computation_requirements(&self) -> ComputationRequirements {
706        // MostLiquidAlgorithm uses token prices for two purposes:
707        // 1. Converting gas costs from wei to output token terms (net_amount_out)
708        // 2. Normalizing pool depth to gas token units for path scoring (from_sim_and_derived)
709        //
710        // Token prices are marked as `allow_stale` since they don't change much
711        // block-to-block. Stale prices affect scoring order (not correctness)
712        // and gas cost estimation accuracy.
713        ComputationRequirements::none()
714            .allow_stale("token_prices")
715            .expect("Conflicting Computation Requirements")
716    }
717
718    fn timeout(&self) -> Duration {
719        self.timeout
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use std::collections::HashSet;
726
727    use rstest::rstest;
728    use tycho_simulation::{
729        tycho_core::simulation::protocol_sim::Price,
730        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
731    };
732
733    use super::*;
734    use crate::{
735        algorithm::test_utils::{
736            addr, component,
737            fixtures::{addrs, diamond_graph, linear_graph, parallel_graph},
738            market_read, order, setup_market, token, MockProtocolSim, ONE_ETH,
739        },
740        derived::{
741            computation::{FailedItem, FailedItemError},
742            types::TokenGasPrices,
743            DerivedData,
744        },
745        graph::GraphManager,
746        types::OrderSide,
747    };
748
749    fn wrap_market(market: MarketState) -> MarketData {
750        MarketData::new(std::sync::Arc::new(tokio::sync::RwLock::new(market)))
751    }
752
753    /// Creates a SharedDerivedDataRef with token prices set for testing.
754    ///
755    /// The price is set to numerator=1, denominator=1, which means:
756    /// gas_cost_in_token = gas_cost_wei * 1 / 1 = gas_cost_wei
757    fn setup_derived_with_token_prices(token_addresses: &[Address]) -> SharedDerivedDataRef {
758        let mut token_prices: TokenGasPrices = HashMap::new();
759        for addr in token_addresses {
760            // Price where 1 wei of gas = 1 unit of token
761            token_prices.insert(
762                addr.clone(),
763                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
764            );
765        }
766
767        let mut derived_data = DerivedData::new();
768        derived_data.set_token_prices(token_prices, vec![], 1, true);
769        std::sync::Arc::new(tokio::sync::RwLock::new(derived_data))
770    }
771    // ==================== try_score_path Tests ====================
772
773    #[test]
774    fn test_try_score_path_calculates_correctly() {
775        let (a, b, c, _) = addrs();
776        let mut m = linear_graph();
777
778        // A->B: spot=2.0, depth=1000, fee=0.3%; B->C: spot=0.5, depth=500, fee=0.1%
779        m.set_edge_weight(&"ab".to_string(), &a, &b, DepthAndPrice::new(2.0, 1000.0), false)
780            .unwrap();
781        m.set_edge_weight(&"bc".to_string(), &b, &c, DepthAndPrice::new(0.5, 500.0), false)
782            .unwrap();
783
784        // Use find_paths to get the 2-hop path A->B->C
785        let graph = m.graph();
786        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &c, 2, 2, None).unwrap();
787        assert_eq!(paths.len(), 1);
788        let path = &paths[0];
789
790        // price = 2.0 * 0.997 * 0.5 * 0.999, min_depth = 500.0
791        let expected = 2.0 * 0.5 * 500.0;
792        let score = MostLiquidAlgorithm::try_score_path(path).unwrap();
793        assert_eq!(score, expected, "expected {expected}, got {score}");
794    }
795
796    #[test]
797    fn test_try_score_path_empty_returns_none() {
798        let path: Path<DepthAndPrice> = Path::new();
799        assert_eq!(MostLiquidAlgorithm::try_score_path(&path), None);
800    }
801
802    #[test]
803    fn test_try_score_path_missing_weight_returns_none() {
804        let (a, b, _, _) = addrs();
805        let m = linear_graph();
806        let graph = m.graph();
807        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &b, 1, 1, None).unwrap();
808        assert_eq!(paths.len(), 1);
809        assert!(MostLiquidAlgorithm::try_score_path(&paths[0]).is_none());
810    }
811
812    #[test]
813    fn test_try_score_path_circular_route() {
814        // Test scoring a circular path A -> B -> A
815        let (a, b, _, _) = addrs();
816        let mut m = linear_graph();
817
818        // Set weights for both directions of the ab pool
819        // A->B: spot=2.0, depth=1000, fee=0.3%
820        // B->A: spot=0.6, depth=800, fee=0.3%
821        m.set_edge_weight(&"ab".to_string(), &a, &b, DepthAndPrice::new(2.0, 1000.0), false)
822            .unwrap();
823        m.set_edge_weight(&"ab".to_string(), &b, &a, DepthAndPrice::new(0.6, 800.0), false)
824            .unwrap();
825
826        let graph = m.graph();
827        // Find A->B->A paths (circular, 2 hops)
828        let paths = MostLiquidAlgorithm::find_paths(graph, &a, &a, 2, 2, None).unwrap();
829
830        // Should find at least one path
831        assert_eq!(paths.len(), 1);
832
833        // Score should be: price * min_depth
834        // price = 2.0 * 0.997 * 0.6 * 0.997 = 1.1928...
835        // min_depth = min(1000, 800) = 800
836        // score = 1.1928 * 800 ≈ 954.3
837        let score = MostLiquidAlgorithm::try_score_path(&paths[0]).unwrap();
838        let expected = 2.0 * 0.6 * 800.0;
839        assert_eq!(score, expected, "expected {expected}, got {score}");
840    }
841
842    fn make_mock_sim() -> MockProtocolSim {
843        MockProtocolSim::new(2.0)
844    }
845
846    fn pair_key(comp: &str, b_in: u8, b_out: u8) -> (String, Address, Address) {
847        (comp.to_string(), addr(b_in), addr(b_out))
848    }
849
850    fn pair_key_str(comp: &str, b_in: u8, b_out: u8) -> String {
851        format!("{comp}/{}/{}", addr(b_in), addr(b_out))
852    }
853
854    fn make_token_prices(addresses: &[Address]) -> TokenGasPrices {
855        let mut prices = TokenGasPrices::new();
856        for addr in addresses {
857            // 1:1 price (1 token unit = 1 gas token unit)
858            prices.insert(
859                addr.clone(),
860                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
861            );
862        }
863        prices
864    }
865
866    #[test]
867    fn test_from_sim_and_derived_failed_spot_price_returns_none() {
868        let key = pair_key("pool1", 0x01, 0x02);
869        let key_str = pair_key_str("pool1", 0x01, 0x02);
870        let tok_in = token(0x01, "A");
871        let tok_out = token(0x02, "B");
872
873        let mut derived = DerivedData::new();
874        // spot price fails, pool depth not computed
875        derived.set_spot_prices(
876            Default::default(),
877            vec![FailedItem {
878                key: key_str,
879                error: FailedItemError::SimulationFailed("sim error".into()),
880            }],
881            10,
882            true,
883        );
884        derived.set_pool_depths(Default::default(), vec![], 10, true);
885        derived.set_token_prices(
886            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
887            vec![],
888            10,
889            true,
890        );
891
892        let sim = make_mock_sim();
893        let result =
894            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
895                &sim, &key.0, &tok_in, &tok_out, &derived,
896            );
897
898        assert!(result.is_none());
899    }
900
901    #[test]
902    fn test_from_sim_and_derived_failed_pool_depth_returns_none() {
903        let key = pair_key("pool1", 0x01, 0x02);
904        let key_str = pair_key_str("pool1", 0x01, 0x02);
905        let tok_in = token(0x01, "A");
906        let tok_out = token(0x02, "B");
907
908        let mut derived = DerivedData::new();
909        // spot price succeeds
910        let mut prices = crate::derived::types::SpotPrices::default();
911        prices.insert(key.clone(), 1.5);
912        derived.set_spot_prices(prices, vec![], 10, true);
913        // pool depth fails
914        derived.set_pool_depths(
915            Default::default(),
916            vec![FailedItem {
917                key: key_str,
918                error: FailedItemError::SimulationFailed("depth error".into()),
919            }],
920            10,
921            true,
922        );
923        derived.set_token_prices(
924            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
925            vec![],
926            10,
927            true,
928        );
929
930        let sim = make_mock_sim();
931        let result =
932            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
933                &sim, &key.0, &tok_in, &tok_out, &derived,
934            );
935
936        assert!(result.is_none());
937    }
938
939    #[test]
940    fn test_from_sim_and_derived_both_failed_returns_none() {
941        let key = pair_key("pool1", 0x01, 0x02);
942        let key_str = pair_key_str("pool1", 0x01, 0x02);
943        let tok_in = token(0x01, "A");
944        let tok_out = token(0x02, "B");
945
946        let mut derived = DerivedData::new();
947        derived.set_spot_prices(
948            Default::default(),
949            vec![FailedItem {
950                key: key_str.clone(),
951                error: FailedItemError::SimulationFailed("spot error".into()),
952            }],
953            10,
954            true,
955        );
956        derived.set_pool_depths(
957            Default::default(),
958            vec![FailedItem {
959                key: key_str,
960                error: FailedItemError::SimulationFailed("depth error".into()),
961            }],
962            10,
963            true,
964        );
965        derived.set_token_prices(
966            make_token_prices(&[tok_in.address.clone(), tok_out.address.clone()]),
967            vec![],
968            10,
969            true,
970        );
971
972        let sim = make_mock_sim();
973        let result =
974            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
975                &sim, &key.0, &tok_in, &tok_out, &derived,
976            );
977
978        assert!(result.is_none());
979    }
980
981    #[test]
982    fn test_from_sim_and_derived_missing_token_price_returns_none() {
983        let key = pair_key("pool1", 0x01, 0x02);
984        let tok_in = token(0x01, "A");
985        let tok_out = token(0x02, "B");
986
987        let mut derived = DerivedData::new();
988        // Spot price and pool depth both present
989        let mut prices = crate::derived::types::SpotPrices::default();
990        prices.insert(key.clone(), 1.5);
991        derived.set_spot_prices(prices, vec![], 10, true);
992
993        let mut depths = crate::derived::types::PoolDepths::default();
994        depths.insert(key.clone(), BigUint::from(1000u64));
995        derived.set_pool_depths(depths, vec![], 10, true);
996
997        // No token prices set — normalization should return None
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!(
1006            result.is_none(),
1007            "should return None when token price is missing for depth normalization"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_from_sim_and_derived_normalizes_depth_to_eth() {
1013        let key = pair_key("pool1", 0x01, 0x02);
1014        let tok_in = token(0x01, "A");
1015        let tok_out = token(0x02, "B");
1016
1017        let mut derived = DerivedData::new();
1018
1019        // Spot price
1020        let mut spot = crate::derived::types::SpotPrices::default();
1021        spot.insert(key.clone(), 2.0);
1022        derived.set_spot_prices(spot, vec![], 10, true);
1023
1024        // Raw depth: 2_000_000 token_in units
1025        let mut depths = crate::derived::types::PoolDepths::default();
1026        depths.insert(key.clone(), BigUint::from(2_000_000u64));
1027        derived.set_pool_depths(depths, vec![], 10, true);
1028
1029        // Token price: 2000 token_in per 1 ETH (numerator=2000, denominator=1)
1030        // So 2_000_000 raw units / 2000 = 1000 ETH
1031        let mut token_prices = TokenGasPrices::new();
1032        token_prices.insert(
1033            tok_in.address.clone(),
1034            Price { numerator: BigUint::from(2000u64), denominator: BigUint::from(1u64) },
1035        );
1036        derived.set_token_prices(token_prices, vec![], 10, true);
1037
1038        let sim = make_mock_sim();
1039        let result =
1040            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1041                &sim, &key.0, &tok_in, &tok_out, &derived,
1042            );
1043
1044        let data = result.expect("should return Some when all data present");
1045        assert!((data.spot_price - 2.0).abs() < f64::EPSILON, "spot price should be 2.0");
1046        // depth_in_eth = 2_000_000 * 1 / 2000 = 1000.0
1047        assert!(
1048            (data.depth - 1000.0).abs() < f64::EPSILON,
1049            "depth should be 1000.0 ETH, got {}",
1050            data.depth
1051        );
1052    }
1053
1054    #[test]
1055    fn test_from_sim_and_derived_normalizes_depth_fractional_price() {
1056        let key = pair_key("pool1", 0x01, 0x02);
1057        let tok_in = token(0x01, "A");
1058        let tok_out = token(0x02, "B");
1059
1060        let mut derived = DerivedData::new();
1061
1062        let mut spot = crate::derived::types::SpotPrices::default();
1063        spot.insert(key.clone(), 0.5);
1064        derived.set_spot_prices(spot, vec![], 10, true);
1065
1066        // Raw depth: 500 token_in units
1067        let mut depths = crate::derived::types::PoolDepths::default();
1068        depths.insert(key.clone(), BigUint::from(500u64));
1069        derived.set_pool_depths(depths, vec![], 10, true);
1070
1071        // Token price: numerator=3, denominator=2 -> 1.5 tokens per ETH
1072        // depth_in_eth = 500 * 2 / 3 = 333.333...
1073        let mut token_prices = TokenGasPrices::new();
1074        token_prices.insert(
1075            tok_in.address.clone(),
1076            Price { numerator: BigUint::from(3u64), denominator: BigUint::from(2u64) },
1077        );
1078        derived.set_token_prices(token_prices, vec![], 10, true);
1079
1080        let sim = make_mock_sim();
1081        let result =
1082            <DepthAndPrice as crate::graph::EdgeWeightFromSimAndDerived>::from_sim_and_derived(
1083                &sim, &key.0, &tok_in, &tok_out, &derived,
1084            );
1085
1086        let data = result.expect("should return Some when all data present");
1087        let expected_depth = 500.0 * 2.0 / 3.0;
1088        assert!(
1089            (data.depth - expected_depth).abs() < 1e-10,
1090            "depth should be {expected_depth}, got {}",
1091            data.depth
1092        );
1093    }
1094
1095    // ==================== find_paths Tests ====================
1096
1097    fn all_ids(paths: Vec<Path<'_, DepthAndPrice>>) -> HashSet<Vec<&str>> {
1098        paths
1099            .iter()
1100            .map(|p| {
1101                p.iter()
1102                    .map(|(_, e, _)| e.component_id.as_str())
1103                    .collect()
1104            })
1105            .collect()
1106    }
1107
1108    #[test]
1109    fn test_find_paths_linear_forward_and_reverse() {
1110        let (a, b, c, d) = addrs();
1111        let m = linear_graph();
1112        let g = m.graph();
1113
1114        // Forward: A->B (1 hop), A->C (2 hops), A->D (3 hops)
1115        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 1, None).unwrap();
1116        assert_eq!(all_ids(p), HashSet::from([vec!["ab"]]));
1117
1118        let p = MostLiquidAlgorithm::find_paths(g, &a, &c, 1, 2, None).unwrap();
1119        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bc"]]));
1120
1121        let p = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 3, None).unwrap();
1122        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bc", "cd"]]));
1123
1124        // Reverse: D->A (bidirectional pools)
1125        let p = MostLiquidAlgorithm::find_paths(g, &d, &a, 1, 3, None).unwrap();
1126        assert_eq!(all_ids(p), HashSet::from([vec!["cd", "bc", "ab"]]));
1127    }
1128
1129    #[test]
1130    fn test_find_paths_respects_hop_bounds() {
1131        let (a, _, c, d) = addrs();
1132        let m = linear_graph();
1133        let g = m.graph();
1134
1135        // A->D needs 3 hops, max_hops=2 finds nothing
1136        assert!(MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2, None)
1137            .unwrap()
1138            .is_empty());
1139
1140        // A->C is 2 hops, min_hops=3 finds nothing
1141        assert!(MostLiquidAlgorithm::find_paths(g, &a, &c, 3, 3, None)
1142            .unwrap()
1143            .is_empty());
1144    }
1145
1146    #[test]
1147    fn test_find_paths_parallel_pools() {
1148        let (a, b, c, _) = addrs();
1149        let m = parallel_graph();
1150        let g = m.graph();
1151
1152        // A->B: 3 parallel pools = 3 paths
1153        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 1, None).unwrap();
1154        assert_eq!(all_ids(p), HashSet::from([vec!["ab1"], vec!["ab2"], vec!["ab3"]]));
1155
1156        // A->C: 3 A->B pools × 2 B->C pools = 6 paths
1157        let p = MostLiquidAlgorithm::find_paths(g, &a, &c, 1, 2, None).unwrap();
1158        assert_eq!(
1159            all_ids(p),
1160            HashSet::from([
1161                vec!["ab1", "bc1"],
1162                vec!["ab1", "bc2"],
1163                vec!["ab2", "bc1"],
1164                vec!["ab2", "bc2"],
1165                vec!["ab3", "bc1"],
1166                vec!["ab3", "bc2"],
1167            ])
1168        );
1169    }
1170
1171    #[test]
1172    fn test_find_paths_diamond_multiple_routes() {
1173        let (a, _, _, d) = addrs();
1174        let m = diamond_graph();
1175        let g = m.graph();
1176
1177        // A->D: two 2-hop paths
1178        let p = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2, None).unwrap();
1179        assert_eq!(all_ids(p), HashSet::from([vec!["ab", "bd"], vec!["ac", "cd"]]));
1180    }
1181
1182    #[test]
1183    fn test_find_paths_no_intermediate_cycles() {
1184        let (a, b, _, _) = addrs();
1185        let m = linear_graph();
1186        let g = m.graph();
1187
1188        // A->B with max_hops=3: only the direct 1-hop path is valid.
1189        // Revisit paths like A->B->C->B or A->B->B->B are pruned because
1190        // they create intermediate cycles unsupported by Tycho execution
1191        // (only first == last cycles are allowed, i.e. from == to).
1192        let p = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 3, None).unwrap();
1193        assert_eq!(all_ids(p), HashSet::from([vec!["ab"]]));
1194    }
1195
1196    #[test]
1197    fn test_find_paths_cyclic_same_source_dest() {
1198        let (a, _, _, _) = addrs();
1199        // Use parallel_graph with 3 A<->B pools to verify all combinations
1200        let m = parallel_graph();
1201        let g = m.graph();
1202
1203        // A->A (cyclic path) with 2 hops: should find all 9 combinations (3 pools × 3 pools)
1204        // Note: min_hops=2 because cyclic paths require at least 2 hops
1205        let p = MostLiquidAlgorithm::find_paths(g, &a, &a, 2, 2, None).unwrap();
1206        assert_eq!(
1207            all_ids(p),
1208            HashSet::from([
1209                vec!["ab1", "ab1"],
1210                vec!["ab1", "ab2"],
1211                vec!["ab1", "ab3"],
1212                vec!["ab2", "ab1"],
1213                vec!["ab2", "ab2"],
1214                vec!["ab2", "ab3"],
1215                vec!["ab3", "ab1"],
1216                vec!["ab3", "ab2"],
1217                vec!["ab3", "ab3"],
1218            ])
1219        );
1220    }
1221
1222    #[rstest]
1223    #[case::source_not_in_graph(false, true)]
1224    #[case::dest_not_in_graph(true, false)]
1225    fn test_find_paths_token_not_in_graph(#[case] from_exists: bool, #[case] to_exists: bool) {
1226        // Graph contains tokens A (0x0A) and B (0x0B) from linear_graph fixture
1227        let (a, b, _, _) = addrs();
1228        let non_existent = addr(0x99);
1229        let m = linear_graph();
1230        let g = m.graph();
1231
1232        let from = if from_exists { a } else { non_existent.clone() };
1233        let to = if to_exists { b } else { non_existent };
1234
1235        let result = MostLiquidAlgorithm::find_paths(g, &from, &to, 1, 3, None);
1236
1237        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1238    }
1239
1240    #[rstest]
1241    #[case::min_greater_than_max(3, 1)]
1242    #[case::min_hops_zero(0, 1)]
1243    fn test_find_paths_invalid_configuration(#[case] min_hops: usize, #[case] max_hops: usize) {
1244        let (a, b, _, _) = addrs();
1245        let m = linear_graph();
1246        let g = m.graph();
1247
1248        assert!(matches!(
1249            MostLiquidAlgorithm::find_paths(g, &a, &b, min_hops, max_hops, None)
1250                .err()
1251                .unwrap(),
1252            AlgorithmError::InvalidConfiguration { reason: _ }
1253        ));
1254    }
1255
1256    #[test]
1257    fn test_find_paths_bfs_ordering() {
1258        // Build a graph with 1-hop, 2-hop, and 3-hop paths to E:
1259        //   A --[ae]--> E                          (1-hop)
1260        //   A --[ab]--> B --[be]--> E              (2-hop)
1261        //   A --[ac]--> C --[cd]--> D --[de]--> E  (3-hop)
1262        let (a, b, c, d) = addrs();
1263        let e = addr(0x0E);
1264        let mut m = PetgraphStableDiGraphManager::<DepthAndPrice>::new();
1265        let mut t = HashMap::new();
1266        t.insert("ae".into(), vec![a.clone(), e.clone()]);
1267        t.insert("ab".into(), vec![a.clone(), b.clone()]);
1268        t.insert("be".into(), vec![b, e.clone()]);
1269        t.insert("ac".into(), vec![a.clone(), c.clone()]);
1270        t.insert("cd".into(), vec![c, d.clone()]);
1271        t.insert("de".into(), vec![d, e.clone()]);
1272        m.initialize_graph(&t);
1273        let g = m.graph();
1274
1275        let p = MostLiquidAlgorithm::find_paths(g, &a, &e, 1, 3, None).unwrap();
1276
1277        // BFS guarantees paths are ordered by hop count
1278        assert_eq!(p.len(), 3, "Expected 3 paths total");
1279        assert_eq!(p[0].len(), 1, "First path should be 1-hop");
1280        assert_eq!(p[1].len(), 2, "Second path should be 2-hop");
1281        assert_eq!(p[2].len(), 3, "Third path should be 3-hop");
1282    }
1283
1284    // ==================== simulate_path Tests ====================
1285    //
1286    // Note: These tests use MockProtocolSim which is detected as a "native" pool.
1287    // Ideally we should also test VM pool state override behavior (vm_state_override),
1288    // which shares state across all VM components. This would require a mock that
1289    // downcasts to EVMPoolState<PreCachedDB>, or integration tests with real VM pools.
1290
1291    #[test]
1292    fn test_simulate_path_single_hop() {
1293        let token_a = token(0x01, "A");
1294        let token_b = token(0x02, "B");
1295
1296        let (market, manager) =
1297            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1298
1299        let paths = MostLiquidAlgorithm::find_paths(
1300            manager.graph(),
1301            &token_a.address,
1302            &token_b.address,
1303            1,
1304            1,
1305            None,
1306        )
1307        .unwrap();
1308        let path = paths.into_iter().next().unwrap();
1309
1310        let result = MostLiquidAlgorithm::simulate_path(
1311            &path,
1312            market_read(&market).base_market_state(),
1313            None,
1314            BigUint::from(100u64),
1315        )
1316        .unwrap();
1317
1318        assert_eq!(result.route().swaps().len(), 1);
1319        assert_eq!(*result.route().swaps()[0].amount_in(), BigUint::from(100u64));
1320        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(200u64)); // 100 * 2
1321        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
1322    }
1323
1324    #[test]
1325    fn test_simulate_path_multi_hop_chains_amounts() {
1326        let token_a = token(0x01, "A");
1327        let token_b = token(0x02, "B");
1328        let token_c = token(0x03, "C");
1329
1330        let (market, manager) = setup_market(vec![
1331            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1332            ("pool2", &token_b, &token_c, MockProtocolSim::new(3.0)),
1333        ]);
1334
1335        let paths = MostLiquidAlgorithm::find_paths(
1336            manager.graph(),
1337            &token_a.address,
1338            &token_c.address,
1339            2,
1340            2,
1341            None,
1342        )
1343        .unwrap();
1344        let path = paths.into_iter().next().unwrap();
1345
1346        let result = MostLiquidAlgorithm::simulate_path(
1347            &path,
1348            market_read(&market).base_market_state(),
1349            None,
1350            BigUint::from(10u64),
1351        )
1352        .unwrap();
1353
1354        assert_eq!(result.route().swaps().len(), 2);
1355        // First hop: 10 * 2 = 20
1356        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(20u64));
1357        // Second hop: 20 * 3 = 60
1358        assert_eq!(*result.route().swaps()[1].amount_in(), BigUint::from(20u64));
1359        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(60u64));
1360    }
1361
1362    #[test]
1363    fn test_simulate_path_same_pool_twice_uses_updated_state() {
1364        // Route: A -> B -> A through the same pool
1365        // First swap uses multiplier=2, second should use multiplier=3 (updated state)
1366        let token_a = token(0x01, "A");
1367        let token_b = token(0x02, "B");
1368
1369        let (market, manager) =
1370            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1371
1372        // A->B->A path requires min_hops=2, max_hops=2
1373        // Since the graph is bidirectional, we should get A->B->A path
1374        let paths = MostLiquidAlgorithm::find_paths(
1375            manager.graph(),
1376            &token_a.address,
1377            &token_a.address,
1378            2,
1379            2,
1380            None,
1381        )
1382        .unwrap();
1383
1384        // Should only contain the A->B->A path
1385        assert_eq!(paths.len(), 1);
1386        let path = paths[0].clone();
1387
1388        let result = MostLiquidAlgorithm::simulate_path(
1389            &path,
1390            market_read(&market).base_market_state(),
1391            None,
1392            BigUint::from(10u64),
1393        )
1394        .unwrap();
1395
1396        assert_eq!(result.route().swaps().len(), 2);
1397        // First: 10 * 2 = 20
1398        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(20u64));
1399        // Second: 20 / 3 = 6 (state updated, multiplier incremented)
1400        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(6u64));
1401    }
1402
1403    #[test]
1404    fn test_simulate_path_missing_token_returns_data_not_found() {
1405        let token_a = token(0x01, "A");
1406        let token_b = token(0x02, "B");
1407        let token_c = token(0x03, "C");
1408
1409        let (market, _) =
1410            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1411        let market = market_read(&market);
1412
1413        // Add token C to graph but not to market (A->B->C)
1414        let mut topology = market.component_topology();
1415        topology
1416            .insert("pool2".to_string(), vec![token_b.address.clone(), token_c.address.clone()]);
1417        let mut manager = PetgraphStableDiGraphManager::default();
1418        manager.initialize_graph(&topology);
1419
1420        let graph = manager.graph();
1421        let paths =
1422            MostLiquidAlgorithm::find_paths(graph, &token_a.address, &token_c.address, 2, 2, None)
1423                .unwrap();
1424        let path = paths.into_iter().next().unwrap();
1425
1426        let result = MostLiquidAlgorithm::simulate_path(
1427            &path,
1428            market.base_market_state(),
1429            None,
1430            BigUint::from(100u64),
1431        );
1432        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "token", .. })));
1433    }
1434
1435    #[test]
1436    fn test_simulate_path_missing_component_returns_data_not_found() {
1437        let token_a = token(0x01, "A");
1438        let token_b = token(0x02, "B");
1439        let (market, manager) =
1440            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1441
1442        // Remove the component but keep tokens and graph
1443        let mut market_write = market.try_write().unwrap();
1444        market_write.remove_components([&"pool1".to_string()]);
1445        drop(market_write);
1446
1447        let graph = manager.graph();
1448        let paths =
1449            MostLiquidAlgorithm::find_paths(graph, &token_a.address, &token_b.address, 1, 1, None)
1450                .unwrap();
1451        let path = paths.into_iter().next().unwrap();
1452
1453        let result = MostLiquidAlgorithm::simulate_path(
1454            &path,
1455            market_read(&market).base_market_state(),
1456            None,
1457            BigUint::from(100u64),
1458        );
1459        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "component", .. })));
1460    }
1461
1462    // ==================== connector_tokens Tests ====================
1463
1464    #[test]
1465    fn test_connector_tokens_blocks_disallowed_intermediate() {
1466        // Diamond: A->B->D, A->C->D. Only C in allowlist → only A->C->D survives.
1467        let (a, b, c, d) = addrs();
1468        let m = diamond_graph();
1469        let g = m.graph();
1470        let allowed: HashSet<Address> = [c.clone()].into();
1471        let paths = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2, Some(&allowed)).unwrap();
1472        let intermediates: HashSet<&Address> = paths
1473            .iter()
1474            .flat_map(|p| p.iter().map(|(node, _, _)| node))
1475            .filter(|addr| *addr != &a && *addr != &d)
1476            .collect();
1477        // B must not appear; C must appear
1478        assert!(!intermediates.contains(&b), "B should be blocked");
1479        assert!(intermediates.contains(&c), "C should be allowed");
1480    }
1481
1482    #[test]
1483    fn test_connector_tokens_allows_endpoints_even_if_not_listed() {
1484        // Allowlist contains neither token_in nor token_out, but a 1-hop route should still work.
1485        let (a, b, _, _) = addrs();
1486        let m = linear_graph();
1487        let g = m.graph();
1488        let allowed: HashSet<Address> = HashSet::new(); // empty — no intermediates
1489                                                        // 1-hop A->B: destination is reached directly, no intermediate check
1490        let paths = MostLiquidAlgorithm::find_paths(g, &a, &b, 1, 1, Some(&allowed)).unwrap();
1491        assert!(!paths.is_empty(), "1-hop direct route should survive empty allowlist");
1492    }
1493
1494    #[test]
1495    fn test_connector_tokens_none_is_unrestricted() {
1496        // None allowlist → both paths in diamond graph returned
1497        let (a, b, c, d) = addrs();
1498        let m = diamond_graph();
1499        let g = m.graph();
1500        let paths = MostLiquidAlgorithm::find_paths(g, &a, &d, 1, 2, None).unwrap();
1501        let intermediates: HashSet<&Address> = paths
1502            .iter()
1503            .flat_map(|p| p.iter().map(|(node, _, _)| node))
1504            .filter(|addr| *addr != &a && *addr != &d)
1505            .collect();
1506        assert!(intermediates.contains(&b), "B should appear with no restriction");
1507        assert!(intermediates.contains(&c), "C should appear with no restriction");
1508    }
1509
1510    // ==================== find_best_route Tests ====================
1511
1512    #[tokio::test]
1513    async fn test_find_best_route_single_path() {
1514        let token_a = token(0x01, "A");
1515        let token_b = token(0x02, "B");
1516
1517        let (market, manager) =
1518            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1519
1520        let algorithm = MostLiquidAlgorithm::with_config(
1521            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1522        )
1523        .unwrap();
1524        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1525        let result = algorithm
1526            .find_best_route(manager.graph(), market, None, None, &order)
1527            .await
1528            .unwrap();
1529
1530        assert_eq!(result.route().swaps().len(), 1);
1531        assert_eq!(*result.route().swaps()[0].amount_in(), BigUint::from(ONE_ETH));
1532        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1533    }
1534
1535    #[tokio::test]
1536    async fn test_find_best_route_ranks_by_net_amount_out() {
1537        // Tests that route selection is based on net_amount_out (output - gas cost),
1538        // not just gross output. Three parallel pools with different spot_price/gas combos:
1539        //
1540        // Gas price = 100 wei/gas (set by setup_market)
1541        //
1542        // | Pool      | spot_price | gas | Output (1000 in) | Gas Cost (gas*100) | Net   |
1543        // |-----------|------------|-----|------------------|-------------------|-------|
1544        // | best      | 3          | 10  | 3000             | 1000              | 2000  |
1545        // | low_out   | 2          | 5   | 2000             | 500               | 1500  |
1546        // | high_gas  | 4          | 30  | 4000             | 3000              | 1000  |
1547        let token_a = token(0x01, "A");
1548        let token_b = token(0x02, "B");
1549
1550        let (market, manager) = setup_market(vec![
1551            ("best", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(10)),
1552            ("low_out", &token_a, &token_b, MockProtocolSim::new(2.0).with_gas(5)),
1553            ("high_gas", &token_a, &token_b, MockProtocolSim::new(4.0).with_gas(30)),
1554        ]);
1555
1556        let algorithm = MostLiquidAlgorithm::with_config(
1557            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1558        )
1559        .unwrap();
1560        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
1561
1562        // Set up derived data with token prices so gas can be deducted
1563        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1564
1565        let result = algorithm
1566            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1567            .await
1568            .unwrap();
1569
1570        // Should select "best" pool for highest net_amount_out (2000)
1571        assert_eq!(result.route().swaps().len(), 1);
1572        assert_eq!(result.route().swaps()[0].component_id(), "best");
1573        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(3000u64));
1574        assert_eq!(result.net_amount_out(), &BigInt::from(2000)); // 3000 - 1000
1575    }
1576
1577    #[tokio::test]
1578    async fn test_find_best_route_no_path_returns_error() {
1579        let token_a = token(0x01, "A");
1580        let token_b = token(0x02, "B");
1581        let token_c = token(0x03, "C"); // Disconnected
1582
1583        let (market, manager) =
1584            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1585
1586        let algorithm = MostLiquidAlgorithm::new();
1587        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1588
1589        let result = algorithm
1590            .find_best_route(manager.graph(), market, None, None, &order)
1591            .await;
1592        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1593    }
1594
1595    #[tokio::test]
1596    async fn test_find_best_route_multi_hop() {
1597        let token_a = token(0x01, "A");
1598        let token_b = token(0x02, "B");
1599        let token_c = token(0x03, "C");
1600
1601        let (market, manager) = setup_market(vec![
1602            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1603            ("pool2", &token_b, &token_c, MockProtocolSim::new(3.0)),
1604        ]);
1605
1606        let algorithm = MostLiquidAlgorithm::with_config(
1607            AlgorithmConfig::new(1, 2, Duration::from_millis(100), None).unwrap(),
1608        )
1609        .unwrap();
1610        let order = order(&token_a, &token_c, ONE_ETH, OrderSide::Sell);
1611
1612        let result = algorithm
1613            .find_best_route(manager.graph(), market, None, None, &order)
1614            .await
1615            .unwrap();
1616
1617        // A->B: ONE_ETH*2, B->C: (ONE_ETH*2)*3
1618        assert_eq!(result.route().swaps().len(), 2);
1619        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1620        assert_eq!(result.route().swaps()[0].component_id(), "pool1".to_string());
1621        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(ONE_ETH * 2 * 3));
1622        assert_eq!(result.route().swaps()[1].component_id(), "pool2".to_string());
1623    }
1624
1625    #[tokio::test]
1626    async fn test_find_best_route_skips_paths_without_edge_weights() {
1627        // Pool1 has edge weights (scoreable), Pool2 doesn't (filtered out during scoring)
1628        let token_a = token(0x01, "A");
1629        let token_b = token(0x02, "B");
1630
1631        // Set up market with both pools using new API
1632        let mut market = MarketState::new();
1633        let pool1_state = MockProtocolSim::new(2.0);
1634        let pool2_state = MockProtocolSim::new(3.0); // Higher multiplier but no edge weight
1635
1636        let pool1_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1637        let pool2_comp = component("pool2", &[token_a.clone(), token_b.clone()]);
1638
1639        // Set gas price (required for simulation)
1640        market.update_gas_price(BlockGasPrice {
1641            block_number: 1,
1642            block_hash: Default::default(),
1643            block_timestamp: 0,
1644            pricing: GasPrice::Legacy { gas_price: BigUint::from(1u64) },
1645        });
1646
1647        // Insert components
1648        market.upsert_components(vec![pool1_comp, pool2_comp]);
1649
1650        // Insert states
1651        market.update_states(vec![
1652            ("pool1".to_string(), Box::new(pool1_state.clone()) as Box<dyn ProtocolSim>),
1653            ("pool2".to_string(), Box::new(pool2_state) as Box<dyn ProtocolSim>),
1654        ]);
1655
1656        // Insert tokens
1657        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1658
1659        // Initialize graph with both pools
1660        let mut manager = PetgraphStableDiGraphManager::default();
1661        manager.initialize_graph(&market.component_topology());
1662
1663        // Only set edge weights for pool1, NOT pool2
1664        let weight = DepthAndPrice::from_protocol_sim(&pool1_state, &token_a, &token_b).unwrap();
1665        manager
1666            .set_edge_weight(
1667                &"pool1".to_string(),
1668                &token_a.address,
1669                &token_b.address,
1670                weight,
1671                false,
1672            )
1673            .unwrap();
1674
1675        // Use max_hops=1 to focus only on direct 1-hop paths
1676        let algorithm = MostLiquidAlgorithm::with_config(
1677            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1678        )
1679        .unwrap();
1680        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1681        let market = wrap_market(market);
1682        let result = algorithm
1683            .find_best_route(manager.graph(), market, None, None, &order)
1684            .await
1685            .unwrap();
1686
1687        // Should use pool1 (only scoreable path), despite pool2 having better multiplier
1688        assert_eq!(result.route().swaps().len(), 1);
1689        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
1690        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(ONE_ETH * 2));
1691    }
1692
1693    #[tokio::test]
1694    async fn test_find_best_route_no_scorable_paths() {
1695        // All paths exist but none have edge weights (can't be scored)
1696        let token_a = token(0x01, "A");
1697        let token_b = token(0x02, "B");
1698
1699        let mut market = MarketState::new();
1700        let pool_state = MockProtocolSim::new(2.0);
1701        let pool_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1702
1703        // Set gas price (required for simulation)
1704        market.update_gas_price(BlockGasPrice {
1705            block_number: 1,
1706            block_hash: Default::default(),
1707            block_timestamp: 0,
1708            pricing: GasPrice::Eip1559 {
1709                base_fee_per_gas: BigUint::from(1u64),
1710                max_priority_fee_per_gas: BigUint::from(0u64),
1711            },
1712        });
1713
1714        market.upsert_components(vec![pool_comp]);
1715        market.update_states(vec![(
1716            "pool1".to_string(),
1717            Box::new(pool_state) as Box<dyn ProtocolSim>,
1718        )]);
1719        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1720
1721        // Initialize graph but DO NOT set any edge weights
1722        let mut manager = PetgraphStableDiGraphManager::default();
1723        manager.initialize_graph(&market.component_topology());
1724
1725        let algorithm = MostLiquidAlgorithm::new();
1726        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1727        let market = wrap_market(market);
1728
1729        let result = algorithm
1730            .find_best_route(manager.graph(), market, None, None, &order)
1731            .await;
1732        assert!(matches!(
1733            result,
1734            Err(AlgorithmError::NoPath { reason: NoPathReason::NoScorablePaths, .. })
1735        ));
1736    }
1737
1738    #[tokio::test]
1739    async fn test_find_best_route_gas_exceeds_output_returns_negative_net() {
1740        let token_a = token(0x01, "A");
1741        let token_b = token(0x02, "B");
1742
1743        let (market, manager) =
1744            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1745        let mut market_write = market.try_write().unwrap();
1746
1747        // Set a non-zero gas price so gas cost exceeds tiny output
1748        // gas_cost = 50_000 * (1_000_000 + 1_000_000) = 100_000_000_000 >> 2 wei output
1749        market_write.update_gas_price(BlockGasPrice {
1750            block_number: 1,
1751            block_hash: Default::default(),
1752            block_timestamp: 0,
1753            pricing: GasPrice::Eip1559 {
1754                base_fee_per_gas: BigUint::from(1_000_000u64),
1755                max_priority_fee_per_gas: BigUint::from(1_000_000u64),
1756            },
1757        });
1758        drop(market_write); // Release write lock
1759
1760        let algorithm = MostLiquidAlgorithm::new();
1761        let order = order(&token_a, &token_b, 1, OrderSide::Sell); // 1 wei input -> 2 wei output
1762
1763        // Set up derived data with token prices so gas can be deducted
1764        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1765
1766        // Route should still be returned, but with negative net_amount_out
1767        let result = algorithm
1768            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1769            .await
1770            .expect("should return route even with negative net_amount_out");
1771
1772        // Verify the route has swaps
1773        assert_eq!(result.route().swaps().len(), 1);
1774        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(2u64)); // 1 * 2 = 2 wei
1775
1776        // Verify it's: 2 - 200_000_000_000 = -199_999_999_998
1777        let expected_net = BigInt::from(2) - BigInt::from(100_000_000_000u64);
1778        assert_eq!(result.net_amount_out(), &expected_net);
1779    }
1780
1781    #[tokio::test]
1782    async fn test_find_best_route_insufficient_liquidity() {
1783        // Pool has limited liquidity (1000 wei) but we try to swap ONE_ETH
1784        let token_a = token(0x01, "A");
1785        let token_b = token(0x02, "B");
1786
1787        let (market, manager) = setup_market(vec![(
1788            "pool1",
1789            &token_a,
1790            &token_b,
1791            MockProtocolSim::new(2.0).with_liquidity(1000),
1792        )]);
1793
1794        let algorithm = MostLiquidAlgorithm::new();
1795        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell); // More than 1000 wei liquidity
1796
1797        let result = algorithm
1798            .find_best_route(manager.graph(), market, None, None, &order)
1799            .await;
1800        assert!(matches!(result, Err(AlgorithmError::InsufficientLiquidity)));
1801    }
1802
1803    #[tokio::test]
1804    async fn test_find_best_route_missing_gas_price_returns_error() {
1805        // Test that missing gas price returns DataNotFound error, not InsufficientLiquidity
1806        let token_a = token(0x01, "A");
1807        let token_b = token(0x02, "B");
1808
1809        let mut market = MarketState::new();
1810        let pool_state = MockProtocolSim::new(2.0);
1811        let pool_comp = component("pool1", &[token_a.clone(), token_b.clone()]);
1812
1813        // DO NOT set gas price - this is what we're testing
1814        market.upsert_components(vec![pool_comp]);
1815        market.update_states(vec![(
1816            "pool1".to_string(),
1817            Box::new(pool_state.clone()) as Box<dyn ProtocolSim>,
1818        )]);
1819        market.upsert_tokens(vec![token_a.clone(), token_b.clone()]);
1820
1821        // Initialize graph and set edge weights
1822        let mut manager = PetgraphStableDiGraphManager::default();
1823        manager.initialize_graph(&market.component_topology());
1824        let weight = DepthAndPrice::from_protocol_sim(&pool_state, &token_a, &token_b).unwrap();
1825        manager
1826            .set_edge_weight(
1827                &"pool1".to_string(),
1828                &token_a.address,
1829                &token_b.address,
1830                weight,
1831                false,
1832            )
1833            .unwrap();
1834
1835        let algorithm = MostLiquidAlgorithm::new();
1836        let order = order(&token_a, &token_b, ONE_ETH, OrderSide::Sell);
1837        let market = wrap_market(market);
1838
1839        let result = algorithm
1840            .find_best_route(manager.graph(), market, None, None, &order)
1841            .await;
1842
1843        // Should get DataNotFound for gas price, not InsufficientLiquidity
1844        assert!(matches!(result, Err(AlgorithmError::DataNotFound { kind: "gas price", .. })));
1845    }
1846
1847    #[tokio::test]
1848    async fn test_find_best_route_circular_arbitrage() {
1849        let token_a = token(0x01, "A");
1850        let token_b = token(0x02, "B");
1851
1852        // MockProtocolSim::get_amount_out multiplies by spot_price when token_in < token_out.
1853        // After the first swap, spot_price increments to 3.
1854        let (market, manager) =
1855            setup_market(vec![("pool1", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1856
1857        // Use min_hops=2 to require at least 2 hops (circular)
1858        let algorithm = MostLiquidAlgorithm::with_config(
1859            AlgorithmConfig::new(2, 2, Duration::from_millis(100), None).unwrap(),
1860        )
1861        .unwrap();
1862
1863        // Order: swap A for A (circular)
1864        let order = order(&token_a, &token_a, 100, OrderSide::Sell);
1865
1866        let result = algorithm
1867            .find_best_route(manager.graph(), market, None, None, &order)
1868            .await
1869            .unwrap();
1870
1871        // Should have 2 swaps forming a circle
1872        assert_eq!(result.route().swaps().len(), 2, "Should have 2 swaps for circular route");
1873
1874        // First swap: A -> B (100 * 2 = 200)
1875        assert_eq!(*result.route().swaps()[0].token_in(), token_a.address);
1876        assert_eq!(*result.route().swaps()[0].token_out(), token_b.address);
1877        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(200u64));
1878
1879        // Second swap: B -> A (200 / 3 = 66, spot_price incremented to 3)
1880        assert_eq!(*result.route().swaps()[1].token_in(), token_b.address);
1881        assert_eq!(*result.route().swaps()[1].token_out(), token_a.address);
1882        assert_eq!(*result.route().swaps()[1].amount_out(), BigUint::from(66u64));
1883
1884        // Verify the route starts and ends with the same token
1885        assert_eq!(result.route().swaps()[0].token_in(), result.route().swaps()[1].token_out());
1886    }
1887
1888    #[tokio::test]
1889    async fn test_find_best_route_respects_min_hops() {
1890        // Setup: A->B (1-hop) and A->C->B (2-hop)
1891        // With min_hops=2, should only return the 2-hop path
1892        let token_a = token(0x01, "A");
1893        let token_b = token(0x02, "B");
1894        let token_c = token(0x03, "C");
1895
1896        let (market, manager) = setup_market(vec![
1897            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(10.0)), /* Direct: 1-hop, high
1898                                                                          * output */
1899            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0)), // 2-hop path
1900            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(3.0)), // 2-hop path
1901        ]);
1902
1903        // min_hops=2 should skip the 1-hop direct path
1904        let algorithm = MostLiquidAlgorithm::with_config(
1905            AlgorithmConfig::new(2, 3, Duration::from_millis(100), None).unwrap(),
1906        )
1907        .unwrap();
1908        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1909
1910        // Set up derived data with token prices so gas can be deducted
1911        // This ensures shorter paths are preferred due to lower gas cost
1912        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1913
1914        let result = algorithm
1915            .find_best_route(manager.graph(), market, None, Some(derived), &order)
1916            .await
1917            .unwrap();
1918
1919        // Should use 2-hop path (A->C->B), not the direct 1-hop path
1920        assert_eq!(result.route().swaps().len(), 2, "Should use 2-hop path due to min_hops=2");
1921        assert_eq!(result.route().swaps()[0].component_id(), "pool_ac");
1922        assert_eq!(result.route().swaps()[1].component_id(), "pool_cb");
1923    }
1924
1925    #[tokio::test]
1926    async fn test_find_best_route_respects_max_hops() {
1927        // Setup: Only path is A->B->C (2 hops)
1928        // With max_hops=1, should return NoPath error
1929        let token_a = token(0x01, "A");
1930        let token_b = token(0x02, "B");
1931        let token_c = token(0x03, "C");
1932
1933        let (market, manager) = setup_market(vec![
1934            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1935            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1936        ]);
1937
1938        // max_hops=1 cannot reach C from A (needs 2 hops)
1939        let algorithm = MostLiquidAlgorithm::with_config(
1940            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
1941        )
1942        .unwrap();
1943        let order = order(&token_a, &token_c, 100, OrderSide::Sell);
1944
1945        let result = algorithm
1946            .find_best_route(manager.graph(), market, None, None, &order)
1947            .await;
1948        assert!(
1949            matches!(result, Err(AlgorithmError::NoPath { .. })),
1950            "Should return NoPath when max_hops is insufficient"
1951        );
1952    }
1953
1954    #[tokio::test]
1955    async fn test_find_best_route_timeout_returns_best_so_far() {
1956        // Setup: Many parallel paths to process
1957        // With very short timeout, should return the best route found before timeout
1958        // or Timeout error if no route was completed
1959        let token_a = token(0x01, "A");
1960        let token_b = token(0x02, "B");
1961
1962        // Create many parallel pools to ensure multiple paths need processing
1963        let (market, manager) = setup_market(vec![
1964            ("pool1", &token_a, &token_b, MockProtocolSim::new(1.0)),
1965            ("pool2", &token_a, &token_b, MockProtocolSim::new(2.0)),
1966            ("pool3", &token_a, &token_b, MockProtocolSim::new(3.0)),
1967            ("pool4", &token_a, &token_b, MockProtocolSim::new(4.0)),
1968            ("pool5", &token_a, &token_b, MockProtocolSim::new(5.0)),
1969        ]);
1970
1971        // timeout=0ms should timeout after processing some paths
1972        let algorithm = MostLiquidAlgorithm::with_config(
1973            AlgorithmConfig::new(1, 1, Duration::from_millis(0), None).unwrap(),
1974        )
1975        .unwrap();
1976        let order = order(&token_a, &token_b, 100, OrderSide::Sell);
1977
1978        let result = algorithm
1979            .find_best_route(manager.graph(), market, None, None, &order)
1980            .await;
1981
1982        // With 0ms timeout, we either get:
1983        // - A route (if at least one path completed before timeout check)
1984        // - Timeout error (if no path completed)
1985        // Both are valid outcomes - the key is we don't hang
1986        match result {
1987            Ok(r) => {
1988                // If we got a route, verify it's valid
1989                assert_eq!(r.route().swaps().len(), 1);
1990            }
1991            Err(AlgorithmError::Timeout { .. }) => {
1992                // Timeout is also acceptable
1993            }
1994            Err(e) => panic!("Unexpected error: {:?}", e),
1995        }
1996    }
1997
1998    // ==================== Algorithm Trait Getter Tests ====================
1999
2000    #[rstest::rstest]
2001    #[case::default_config(1, 3, 50)]
2002    #[case::single_hop_only(1, 1, 100)]
2003    #[case::multi_hop_min(2, 5, 200)]
2004    #[case::zero_timeout(1, 3, 0)]
2005    #[case::large_values(10, 100, 10000)]
2006    fn test_algorithm_config_getters(
2007        #[case] min_hops: usize,
2008        #[case] max_hops: usize,
2009        #[case] timeout_ms: u64,
2010    ) {
2011        use crate::algorithm::Algorithm;
2012
2013        let algorithm = MostLiquidAlgorithm::with_config(
2014            AlgorithmConfig::new(min_hops, max_hops, Duration::from_millis(timeout_ms), None)
2015                .unwrap(),
2016        )
2017        .unwrap();
2018
2019        assert_eq!(algorithm.max_hops, max_hops);
2020        assert_eq!(algorithm.timeout, Duration::from_millis(timeout_ms));
2021        assert_eq!(algorithm.name(), "most_liquid");
2022    }
2023
2024    #[test]
2025    fn test_algorithm_default_config() {
2026        use crate::algorithm::Algorithm;
2027
2028        let algorithm = MostLiquidAlgorithm::new();
2029
2030        assert_eq!(algorithm.max_hops, 3);
2031        assert_eq!(algorithm.timeout, Duration::from_millis(500));
2032        assert_eq!(algorithm.name(), "most_liquid");
2033    }
2034
2035    // ==================== Configuration Validation Tests ====================
2036
2037    #[tokio::test]
2038    async fn test_find_best_route_respects_max_routes_cap() {
2039        // 4 parallel pools. Score = spot_price * min_depth.
2040        // In tests, depth comes from get_limits().0 (sell_limit), which is
2041        // liquidity / (spot_price * (1 - fee)). With fee=0: depth = liquidity / spot_price.
2042        // We vary liquidity to create a clear score ranking:
2043        //   pool4 (score = 1.0 * 4M/1.0 = 4M)
2044        //   pool3 (score = 2.0 * 3M/2.0 = 3M)
2045        //   pool2 (score = 3.0 * 2M/3.0 = 2M)
2046        //   pool1 (score = 4.0 * 1M/4.0 = 1M)
2047        //
2048        // With max_routes=2, only pool4 and pool3 are simulated.
2049        // pool1 has the best simulation output (4x) but the lowest score,
2050        // so it's excluded by the cap.
2051        let token_a = token(0x01, "A");
2052        let token_b = token(0x02, "B");
2053
2054        let (market, manager) = setup_market(vec![
2055            ("pool1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
2056            ("pool2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
2057            ("pool3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
2058            ("pool4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
2059        ]);
2060
2061        // Cap at 2: only the two highest-scored paths are simulated
2062        let algorithm = MostLiquidAlgorithm::with_config(
2063            AlgorithmConfig::new(1, 1, Duration::from_millis(100), Some(2)).unwrap(),
2064        )
2065        .unwrap();
2066        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
2067        let result = algorithm
2068            .find_best_route(manager.graph(), market, None, None, &order)
2069            .await
2070            .unwrap();
2071
2072        // pool1 has the best simulation output (4x) but lowest score, so it's
2073        // excluded by the cap. Among the top-2 scored (pool4=4M, pool3=3M),
2074        // pool3 gives the best simulation output (2x vs 1x).
2075        assert_eq!(result.route().swaps().len(), 1);
2076        assert_eq!(result.route().swaps()[0].component_id(), "pool3");
2077        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(2000u64));
2078    }
2079
2080    #[tokio::test]
2081    async fn test_find_best_route_no_cap_when_max_routes_is_none() {
2082        // Same setup but no cap — pool1 (best output) should win.
2083        let token_a = token(0x01, "A");
2084        let token_b = token(0x02, "B");
2085
2086        let (market, manager) = setup_market(vec![
2087            ("pool1", &token_a, &token_b, MockProtocolSim::new(4.0).with_liquidity(1_000_000)),
2088            ("pool2", &token_a, &token_b, MockProtocolSim::new(3.0).with_liquidity(2_000_000)),
2089            ("pool3", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(3_000_000)),
2090            ("pool4", &token_a, &token_b, MockProtocolSim::new(1.0).with_liquidity(4_000_000)),
2091        ]);
2092
2093        let algorithm = MostLiquidAlgorithm::with_config(
2094            AlgorithmConfig::new(1, 1, Duration::from_millis(100), None).unwrap(),
2095        )
2096        .unwrap();
2097        let order = order(&token_a, &token_b, 1000, OrderSide::Sell);
2098        let result = algorithm
2099            .find_best_route(manager.graph(), market, None, None, &order)
2100            .await
2101            .unwrap();
2102
2103        // All 4 paths simulated, pool1 wins with best output (4x)
2104        assert_eq!(result.route().swaps().len(), 1);
2105        assert_eq!(result.route().swaps()[0].component_id(), "pool1");
2106        assert_eq!(*result.route().swaps()[0].amount_out(), BigUint::from(4000u64));
2107    }
2108
2109    #[test]
2110    fn test_algorithm_config_rejects_zero_max_routes() {
2111        let result = AlgorithmConfig::new(1, 3, Duration::from_millis(100), Some(0));
2112        assert!(matches!(
2113            result,
2114            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("max_routes must be at least 1")
2115        ));
2116    }
2117
2118    #[test]
2119    fn test_algorithm_config_rejects_zero_min_hops() {
2120        let result = AlgorithmConfig::new(0, 3, Duration::from_millis(100), None);
2121        assert!(matches!(
2122            result,
2123            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("min_hops must be at least 1")
2124        ));
2125    }
2126
2127    #[test]
2128    fn test_algorithm_config_rejects_min_greater_than_max() {
2129        let result = AlgorithmConfig::new(5, 3, Duration::from_millis(100), None);
2130        assert!(matches!(
2131            result,
2132            Err(AlgorithmError::InvalidConfiguration { reason }) if reason.contains("cannot exceed")
2133        ));
2134    }
2135}