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