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