Skip to main content

fynd_core/algorithm/
bellman_ford.rs

1//! Bellman-Ford algorithm with SPFA optimization for simulation-driven routing.
2//!
3//! Runs actual pool simulations (`get_amount_out()`) during edge relaxation to find
4//! optimal A-to-B routes that account for slippage, fees, and pool mechanics at the
5//! given trade size.
6//!
7//! Key features:
8//! - **Gas-aware relaxation**: When token prices and gas price are available, relaxation compares
9//!   net amounts (gross output minus cumulative gas cost in token terms) instead of gross output
10//!   alone. Falls back to gross comparison when data is unavailable.
11//! - **Subgraph extraction**: BFS prunes the graph to nodes reachable within `max_hops`
12//! - **SPFA (Shortest Path Faster Algorithm) queuing**: Only re-relaxes edges from nodes whose
13//!   amount improved
14//! - **Forbid revisits**: Skips edges that would revisit a token or pool already in the path
15//!
16//! # Known limitation: SPFA order-dependence
17//!
18//! SPFA reads and writes the same `amount[]` array within a round (unlike
19//! textbook Bellman-Ford which snapshots between rounds). Processing node B
20//! before node C can update intermediate amounts that C then builds on,
21//! producing different routes depending on iteration order. Active nodes are
22//! sorted by `NodeIndex` for determinism, but the chosen ordering is not
23//! guaranteed to find the globally optimal route. A proper fix would be to
24//! snapshot amounts between rounds or use a priority-based processing order.
25
26use std::{
27    collections::{HashMap, HashSet, VecDeque},
28    time::{Duration, Instant},
29};
30
31use num_bigint::{BigInt, BigUint};
32use num_traits::{ToPrimitive, Zero};
33use petgraph::{graph::NodeIndex, prelude::EdgeRef};
34use tracing::{debug, instrument, trace, warn};
35use tycho_simulation::{
36    tycho_common::models::Address,
37    tycho_core::{models::token::Token, simulation::protocol_sim::Price},
38};
39
40use super::{
41    split_primitives::MarketOverrides, Algorithm, AlgorithmConfig, AlgorithmError, NoPathReason,
42};
43use crate::{
44    algorithm::sim_guard::GuardedProtocolSim,
45    derived::{
46        computation::ComputationRequirements,
47        types::{SpotPrices, TokenGasPrices},
48        SharedDerivedDataRef,
49    },
50    feed::market_data::{MarketData, MarketState, StateLabel},
51    graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
52    types::{ComponentId, Order, Route, RouteResult, Swap},
53};
54
55/// BFS subgraph: adjacency list, token node set, and component ID set.
56type Subgraph =
57    (HashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>>, HashSet<NodeIndex>, HashSet<ComponentId>);
58
59/// Everything needed to call `find_single_route` repeatedly without redoing setup.
60///
61/// Built once by `build_context`, which acquires the market and derived locks and snapshots all
62/// relevant states. `find_single_route` uses this snapshot directly — no lock re-acquisition — so
63/// all route evaluations within one order see a consistent view of the same block's pool states.
64pub(crate) struct BellmanFordContext {
65    pub(crate) token_in_node: NodeIndex,
66    pub(crate) token_out_node: NodeIndex,
67    pub(crate) adj: HashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>>,
68    pub(crate) token_map: HashMap<NodeIndex, Token>,
69    pub(crate) market_data: MarketState,
70    pub(crate) gas_price_wei: Option<BigUint>,
71    pub(crate) token_prices: Option<TokenGasPrices>,
72    pub(crate) spot_prices: Option<SpotPrices>,
73    pub(crate) node_address: HashMap<NodeIndex, Address>,
74    pub(crate) max_idx: usize,
75    pub(crate) scoring: RouteScoringMode,
76}
77
78/// Controls how `find_single_route` ranks candidate routes after simulation.
79pub(crate) enum RouteScoringMode {
80    /// Rank by gross output (ignore gas cost). Used when the caller accounts for gas externally.
81    GrossOutput,
82    /// Rank by net output (gross output minus gas cost in output token units). Default.
83    NetOutput,
84}
85
86/// Per-call overrides for `find_single_route`.
87#[derive(Default)]
88pub(crate) struct FindRouteOptions {
89    /// Pool state overrides: degrade or zero-gas specific pools without modifying market data.
90    pub(crate) overrides: MarketOverrides,
91}
92
93/// Output of the SPFA relaxation pass: per-node best-path arrays.
94struct SPFAResult {
95    /// Best gross output amount reachable at each node index.
96    amount: Vec<BigUint>,
97    /// The (predecessor node, pool) that last improved each node's amount.
98    predecessor: Vec<Option<(NodeIndex, ComponentId)>>,
99    /// Gas consumed by the edge that last improved each node's amount.
100    edge_gas: Vec<BigUint>,
101    /// Cumulative spot-price product from token_in to each node (for gas fallback).
102    spot_product: Vec<f64>,
103}
104
105/// Bellman-Ford algorithm with SPFA optimisation for simulation-driven DEX routing.
106///
107/// Finds optimal A→B routes by running actual pool simulations during edge relaxation,
108/// accounting for slippage, fees, and pool mechanics at the requested trade size.
109/// Gas costs are subtracted when price data is available.
110pub struct BellmanFordAlgorithm {
111    max_hops: usize,
112    timeout: Duration,
113    gas_aware: bool,
114    connector_tokens: Option<HashSet<Address>>,
115}
116
117impl Default for BellmanFordAlgorithm {
118    fn default() -> Self {
119        Self::with_config(AlgorithmConfig::default())
120    }
121}
122
123impl BellmanFordAlgorithm {
124    pub(crate) fn with_config(config: AlgorithmConfig) -> Self {
125        Self {
126            max_hops: config.max_hops(),
127            timeout: config.timeout(),
128            gas_aware: config.gas_aware(),
129            connector_tokens: config.connector_tokens().cloned(),
130        }
131    }
132
133    /// One-time async setup for repeated `find_single_route` calls.
134    ///
135    /// Validates the order, extracts the subgraph, acquires the market and derived data
136    /// locks exactly once, and snapshots all state into a [`BellmanFordContext`]. All
137    /// subsequent `find_single_route` calls on the returned context use the same block's
138    /// pool states.
139    pub(crate) async fn build_context(
140        &self,
141        graph: &StableDiGraph<()>,
142        market: MarketData,
143        label: Option<StateLabel>,
144        derived: Option<SharedDerivedDataRef>,
145        order: &Order,
146    ) -> Result<BellmanFordContext, AlgorithmError> {
147        if !order.is_sell() {
148            return Err(AlgorithmError::ExactOutNotSupported);
149        }
150
151        let (token_prices, spot_prices) = if let Some(ref d) = derived {
152            let guard = d.read().await;
153            (guard.token_prices().cloned(), guard.spot_prices().cloned())
154        } else {
155            (None, None)
156        };
157
158        let token_in_node = graph
159            .node_indices()
160            .find(|&n| &graph[n] == order.token_in())
161            .ok_or(AlgorithmError::NoPath {
162                from: order.token_in().clone(),
163                to: order.token_out().clone(),
164                reason: NoPathReason::SourceTokenNotInGraph,
165            })?;
166        let token_out_node = graph
167            .node_indices()
168            .find(|&n| &graph[n] == order.token_out())
169            .ok_or(AlgorithmError::NoPath {
170                from: order.token_in().clone(),
171                to: order.token_out().clone(),
172                reason: NoPathReason::DestinationTokenNotInGraph,
173            })?;
174
175        if token_in_node == token_out_node {
176            return Err(AlgorithmError::NoPath {
177                from: order.token_in().clone(),
178                to: order.token_out().clone(),
179                reason: NoPathReason::NoGraphPath,
180            });
181        }
182
183        // BFS from token_in up to max_hops, building adjacency list and component set.
184        let (adj, token_nodes, component_ids) =
185            Self::get_subgraph(graph, token_in_node, self.max_hops, order)?;
186
187        let market_view = match label.as_ref() {
188            Some(l) => market
189                .read_labeled(l)
190                .await
191                .map_err(|e| AlgorithmError::Other(e.to_string()))?,
192            None => market.read().await,
193        };
194        let token_map: HashMap<NodeIndex, Token> = token_nodes
195            .iter()
196            .filter_map(|&node| {
197                market_view
198                    .get_token(&graph[node])
199                    .cloned()
200                    .map(|t| (node, t))
201            })
202            .collect();
203        let market_data = market_view.extract_subset_with_overlay(&component_ids);
204        let gas_price_wei = market_data
205            .gas_price()
206            .map(|gp| gp.effective_gas_price().clone());
207        drop(market_view);
208
209        let node_address: HashMap<NodeIndex, Address> = token_map
210            .iter()
211            .map(|(&node, token)| (node, token.address.clone()))
212            .collect();
213
214        let max_idx = graph
215            .node_indices()
216            .map(|n| n.index())
217            .max()
218            .unwrap_or(0) +
219            1;
220
221        let scoring = if self.gas_aware {
222            RouteScoringMode::NetOutput
223        } else {
224            RouteScoringMode::GrossOutput
225        };
226
227        debug!(
228            edges = adj
229                .values()
230                .map(Vec::len)
231                .sum::<usize>(),
232            tokens = token_map.len(),
233            "subgraph extracted"
234        );
235
236        Ok(BellmanFordContext {
237            token_in_node,
238            token_out_node,
239            adj,
240            token_map,
241            market_data,
242            gas_price_wei,
243            token_prices,
244            spot_prices,
245            node_address,
246            max_idx,
247            scoring,
248        })
249    }
250
251    /// Runs the SPFA relaxation loop and reconstructs the best route from a pre-built context.
252    ///
253    /// This is the repeatable, synchronous solve phase. Call it multiple times with different
254    /// `opts.overrides` to evaluate alternative pool states without redoing the setup in `ctx`.
255    /// Overrides shadow the corresponding pool in `ctx.market_data` for both relaxation and route
256    /// construction.
257    pub(crate) fn find_single_route(
258        &self,
259        ctx: &BellmanFordContext,
260        order: &Order,
261        opts: FindRouteOptions,
262    ) -> Result<RouteResult, AlgorithmError> {
263        let start = Instant::now();
264
265        let spfa = self.run_spfa(ctx, order, &opts.overrides, start);
266
267        let out_idx = ctx.token_out_node.index();
268        if spfa.amount[out_idx].is_zero() {
269            return Err(AlgorithmError::NoPath {
270                from: order.token_in().clone(),
271                to: order.token_out().clone(),
272                reason: NoPathReason::NoGraphPath,
273            });
274        }
275
276        // Reconstruct path and build route directly from stored distances/gas
277        // (no re-simulation needed since forbid-revisits guarantees relaxation
278        // amounts match sequential execution).
279        let path_edges =
280            Self::reconstruct_path(ctx.token_out_node, ctx.token_in_node, &spfa.predecessor)?;
281
282        let route =
283            Self::build_route(ctx, &path_edges, &spfa.amount, &spfa.edge_gas, &opts.overrides)?;
284
285        let final_amount_out = spfa.amount[out_idx].clone();
286        let gas_price = ctx
287            .gas_price_wei
288            .clone()
289            .unwrap_or_default();
290
291        let net_amount_out = Self::compute_net_amount_out(
292            &final_amount_out,
293            &route,
294            &gas_price,
295            ctx.token_prices.as_ref(),
296            &spfa.spot_product,
297            &ctx.node_address,
298            ctx.token_in_node,
299        )?;
300
301        let result = RouteResult::new(route, net_amount_out, gas_price);
302
303        let solve_time_ms = start.elapsed().as_millis() as u64;
304        debug!(
305            solve_time_ms,
306            hops = result.route().swaps().len(),
307            amount_in = %order.amount(),
308            amount_out = %final_amount_out,
309            net_amount_out = %result.net_amount_out(),
310            "bellman_ford route found"
311        );
312
313        Ok(result)
314    }
315
316    /// Runs SPFA (Shortest Path Faster Algorithm) relaxation over the subgraph and returns per-node
317    /// best-path arrays.
318    ///
319    /// Simulation failures are silently skipped (the edge is dropped). Returns the arrays
320    /// even if the destination was not reached — callers check `amount[out_idx].is_zero()`.
321    fn run_spfa(
322        &self,
323        ctx: &BellmanFordContext,
324        order: &Order,
325        overrides: &MarketOverrides,
326        start: Instant,
327    ) -> SPFAResult {
328        // amount[node] = best gross output reachable at that node.
329        // edge_gas[node] = gas for the edge that last improved amount[node].
330        // cumul_gas[node] = total gas along the best path to this node.
331        let mut amount: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
332        let mut predecessor: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; ctx.max_idx];
333        let mut edge_gas: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
334        let mut cumul_gas: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
335
336        amount[ctx.token_in_node.index()] = order.amount().clone();
337
338        // Track cumulative spot price product from token_in for fallback gas estimation.
339        // spot_product[v] = product of spot prices along the path from token_in to v.
340        let mut spot_product: Vec<f64> = vec![0.0; ctx.max_idx];
341        spot_product[ctx.token_in_node.index()] = 1.0;
342
343        let gas_aware = matches!(ctx.scoring, RouteScoringMode::NetOutput) &&
344            ctx.gas_price_wei.is_some() &&
345            ctx.token_prices.is_some();
346        if !gas_aware && matches!(ctx.scoring, RouteScoringMode::NetOutput) {
347            debug!("gas-aware comparison disabled (missing gas_price or token_prices)");
348        } else if matches!(ctx.scoring, RouteScoringMode::GrossOutput) {
349            debug!("gas-aware comparison disabled by config");
350        }
351
352        let mut active_nodes: Vec<NodeIndex> = vec![ctx.token_in_node];
353
354        for round in 0..self.max_hops {
355            if start.elapsed() >= self.timeout {
356                debug!(round, "timeout during relaxation");
357                break;
358            }
359            if active_nodes.is_empty() {
360                debug!(round, "no active nodes, stopping early");
361                break;
362            }
363
364            let mut next_active: HashSet<NodeIndex> = HashSet::new();
365
366            for &u in &active_nodes {
367                let u_idx = u.index();
368                if amount[u_idx].is_zero() {
369                    continue;
370                }
371
372                let Some(token_u) = ctx.token_map.get(&u) else { continue };
373                let Some(edges) = ctx.adj.get(&u) else { continue };
374
375                for (v, component_id) in edges {
376                    let v_idx = v.index();
377
378                    // Single predecessor walk: skip if target token or pool already in path
379                    if Self::path_has_conflict(u, *v, component_id, &predecessor) {
380                        continue;
381                    }
382
383                    // Skip disallowed connector tokens. Endpoints (token_in / token_out) are
384                    // always permitted regardless of the allowlist.
385                    if let (Some(tokens), Some(v_addr)) =
386                        (&self.connector_tokens, ctx.node_address.get(v))
387                    {
388                        if v_addr != order.token_in() &&
389                            v_addr != order.token_out() &&
390                            !tokens.contains(v_addr)
391                        {
392                            continue;
393                        }
394                    }
395
396                    let Some(token_v) = ctx.token_map.get(v) else { continue };
397
398                    // Overrides market data if passed in options
399                    let sim: &dyn tycho_simulation::tycho_common::simulation::protocol_sim::ProtocolSim =
400                        if let Some(s) = overrides.get(component_id) {
401                            s
402                        } else if let Some(s) = ctx.market_data.get_simulation_state(component_id) {
403                            s
404                        } else {
405                            continue;
406                        };
407
408                    let result =
409                        match sim.get_amount_out_guarded(amount[u_idx].clone(), token_u, token_v) {
410                            Ok(r) => r,
411                            Err(e) => {
412                                trace!(
413                                    component_id,
414                                    error = %e,
415                                    "simulation failed, skipping edge"
416                                );
417                                continue;
418                            }
419                        };
420
421                    let candidate_cumul_gas = &cumul_gas[u_idx] + &result.gas;
422
423                    // Compute spot price product for the candidate path (used for
424                    // gas-aware comparison and for final net amount calculation).
425                    let candidate_spot = Self::compute_edge_spot_product(
426                        spot_product[u_idx],
427                        component_id,
428                        ctx.node_address.get(&u),
429                        ctx.node_address.get(v),
430                        ctx.spot_prices.as_ref(),
431                    );
432
433                    // Gas-aware comparison: compare net amounts (gross - gas cost in token terms)
434                    let is_better = if gas_aware {
435                        let v_price = Self::resolve_token_price(
436                            ctx.node_address.get(v),
437                            ctx.token_prices.as_ref(),
438                            candidate_spot,
439                            ctx.node_address.get(&ctx.token_in_node),
440                        );
441                        let net_candidate = Self::gas_adjusted_amount(
442                            &result.amount,
443                            &candidate_cumul_gas,
444                            ctx.gas_price_wei.as_ref().unwrap(),
445                            v_price.as_ref(),
446                        );
447                        let net_existing = Self::gas_adjusted_amount(
448                            &amount[v_idx],
449                            &cumul_gas[v_idx],
450                            ctx.gas_price_wei.as_ref().unwrap(),
451                            v_price.as_ref(),
452                        );
453                        net_candidate > net_existing
454                    } else {
455                        result.amount > amount[v_idx]
456                    };
457
458                    if is_better {
459                        spot_product[v_idx] = candidate_spot;
460                        amount[v_idx] = result.amount;
461                        predecessor[v_idx] = Some((u, component_id.clone()));
462                        edge_gas[v_idx] = result.gas;
463                        cumul_gas[v_idx] = candidate_cumul_gas;
464                        next_active.insert(*v);
465                    }
466                }
467            }
468
469            active_nodes = next_active.into_iter().collect();
470            // Deterministic order: HashSet iteration is random per process.
471            // This pins SPFA to a fixed propagation order. The chosen order
472            // may not yield the optimal route (see module docs), but the
473            // previous random order was statistically no better.
474            active_nodes.sort_unstable();
475        }
476
477        SPFAResult { amount, predecessor, edge_gas, spot_product }
478    }
479
480    /// Constructs a [`Route`] from a reconstructed path and SPFA output arrays.
481    fn build_route(
482        ctx: &BellmanFordContext,
483        path_edges: &[(NodeIndex, NodeIndex, ComponentId)],
484        amount: &[BigUint],
485        edge_gas: &[BigUint],
486        overrides: &MarketOverrides,
487    ) -> Result<Route, AlgorithmError> {
488        let mut swaps = Vec::with_capacity(path_edges.len());
489        let mut tokens: HashMap<Address, Token> = HashMap::new();
490
491        for (from_node, to_node, component_id) in path_edges {
492            let token_in = ctx
493                .token_map
494                .get(from_node)
495                .ok_or_else(|| AlgorithmError::DataNotFound {
496                    kind: "token",
497                    id: Some(format!("{:?}", from_node)),
498                })?;
499            let token_out = ctx
500                .token_map
501                .get(to_node)
502                .ok_or_else(|| AlgorithmError::DataNotFound {
503                    kind: "token",
504                    id: Some(format!("{:?}", to_node)),
505                })?;
506            let component = ctx
507                .market_data
508                .get_component(component_id)
509                .ok_or_else(|| AlgorithmError::DataNotFound {
510                    kind: "component",
511                    id: Some(component_id.clone()),
512                })?;
513            // Use the override's sim state if available so the route reflects overridden pools.
514            let sim_state = overrides
515                .get(component_id)
516                .or_else(|| {
517                    ctx.market_data
518                        .get_simulation_state(component_id)
519                })
520                .ok_or_else(|| AlgorithmError::DataNotFound {
521                    kind: "simulation state",
522                    id: Some(component_id.clone()),
523                })?;
524
525            swaps.push(Swap::new(
526                component_id.clone(),
527                component.protocol_system.clone(),
528                token_in.address.clone(),
529                token_out.address.clone(),
530                amount[from_node.index()].clone(),
531                amount[to_node.index()].clone(),
532                edge_gas[to_node.index()].clone(),
533                component.clone(),
534                sim_state.clone_box(),
535            ));
536            tokens
537                .entry(token_in.address.clone())
538                .or_insert_with(|| token_in.clone());
539            tokens
540                .entry(token_out.address.clone())
541                .or_insert_with(|| token_out.clone());
542        }
543
544        Ok(Route::new(swaps, tokens)?)
545    }
546
547    /// Computes gas-adjusted net amount: gross_amount - gas_cost_in_token.
548    ///
549    /// If `token_price` is None (no conversion rate available), returns the gross amount
550    /// unchanged (falls back to gross comparison for this node).
551    fn gas_adjusted_amount(
552        gross: &BigUint,
553        cumul_gas: &BigUint,
554        gas_price_wei: &BigUint,
555        token_price: Option<&Price>,
556    ) -> BigInt {
557        match token_price {
558            Some(price) if !price.denominator.is_zero() => {
559                let gas_cost = cumul_gas * gas_price_wei * &price.numerator / &price.denominator;
560                BigInt::from(gross.clone()) - BigInt::from(gas_cost)
561            }
562            _ => BigInt::from(gross.clone()),
563        }
564    }
565
566    /// Computes the cumulative spot price product when extending a path by one edge.
567    ///
568    /// Returns `parent_spot * spot_price(component, token_u, token_v)`.
569    /// Returns 0.0 if the spot price is unavailable (disables the fallback for this path).
570    fn compute_edge_spot_product(
571        parent_spot: f64,
572        component_id: &ComponentId,
573        u_addr: Option<&Address>,
574        v_addr: Option<&Address>,
575        spot_prices: Option<&SpotPrices>,
576    ) -> f64 {
577        if parent_spot == 0.0 {
578            return 0.0;
579        }
580        let (Some(u), Some(v), Some(prices)) = (u_addr, v_addr, spot_prices) else {
581            return 0.0;
582        };
583        let key = (component_id.clone(), u.clone(), v.clone());
584        match prices.get(&key) {
585            Some(&spot) if spot > 0.0 => parent_spot * spot,
586            _ => 0.0,
587        }
588    }
589
590    /// Resolves the gas-to-token conversion rate for gas cost calculation.
591    ///
592    /// 1. Primary: use `token_prices[v_addr]` from derived data (direct lookup).
593    /// 2. Fallback: if `token_prices[token_in]` exists and `spot_product > 0`, estimate the rate as
594    ///    `token_prices[token_in] * spot_product` (converted to a Price).
595    /// 3. Last resort: returns None (gas adjustment skipped for this comparison).
596    fn resolve_token_price(
597        v_addr: Option<&Address>,
598        token_prices: Option<&TokenGasPrices>,
599        spot_product: f64,
600        token_in_addr: Option<&Address>,
601    ) -> Option<Price> {
602        let prices = token_prices?;
603        let addr = v_addr?;
604
605        // Primary: direct lookup
606        if let Some(price) = prices.get(addr) {
607            return Some(price.clone());
608        }
609
610        // Fallback: token_in price * cumulative spot product
611        if spot_product > 0.0 {
612            if let Some(in_price) = token_in_addr.and_then(|a| prices.get(a)) {
613                let in_rate_f64 = in_price.numerator.to_f64()? / in_price.denominator.to_f64()?;
614                let estimated_rate = in_rate_f64 * spot_product;
615                let denom = BigUint::from(10u64).pow(18);
616                let numer_f64 = estimated_rate * 1e18;
617                if numer_f64.is_finite() && numer_f64 > 0.0 {
618                    return Some(Price {
619                        numerator: BigUint::from(numer_f64 as u128),
620                        denominator: denom,
621                    });
622                }
623            }
624        }
625
626        None
627    }
628
629    /// Checks whether the target node or pool conflicts with the existing path to `from`.
630    /// Walks the predecessor chain once, checking both conditions simultaneously.
631    pub(crate) fn path_has_conflict(
632        from: NodeIndex,
633        target_node: NodeIndex,
634        target_pool: &ComponentId,
635        predecessor: &[Option<(NodeIndex, ComponentId)>],
636    ) -> bool {
637        let mut current = from;
638        loop {
639            if current == target_node {
640                return true;
641            }
642            match &predecessor[current.index()] {
643                Some((prev, cid)) => {
644                    if cid == target_pool {
645                        return true;
646                    }
647                    current = *prev;
648                }
649                None => return false,
650            }
651        }
652    }
653
654    /// Reconstructs the path from token_out back to token_in by walking the predecessor
655    /// array.
656    pub(crate) fn reconstruct_path(
657        token_out: NodeIndex,
658        token_in: NodeIndex,
659        predecessor: &[Option<(NodeIndex, ComponentId)>],
660    ) -> Result<Vec<(NodeIndex, NodeIndex, ComponentId)>, AlgorithmError> {
661        let mut path = Vec::new();
662        let mut current = token_out;
663        let mut visited = HashSet::new();
664
665        while current != token_in {
666            if !visited.insert(current) {
667                return Err(AlgorithmError::Other("cycle in predecessor chain".to_string()));
668            }
669
670            let idx = current.index();
671            match &predecessor
672                .get(idx)
673                .and_then(|p| p.as_ref())
674            {
675                Some((prev_node, component_id)) => {
676                    path.push((*prev_node, current, component_id.clone()));
677                    current = *prev_node;
678                }
679                None => {
680                    return Err(AlgorithmError::Other(format!(
681                        "broken predecessor chain at node {idx}"
682                    )));
683                }
684            }
685        }
686
687        path.reverse();
688        Ok(path)
689    }
690
691    /// Extracts the subgraph reachable from `token_in_node` within `max_hops` via BFS.
692    ///
693    /// Returns `(adjacency_list, token_nodes, component_ids)` or `NoPath` if the
694    /// subgraph is empty (no outgoing edges from the source).
695    pub(crate) fn get_subgraph(
696        graph: &StableDiGraph<()>,
697        token_in_node: NodeIndex,
698        max_hops: usize,
699        order: &Order,
700    ) -> Result<Subgraph, AlgorithmError> {
701        let mut adj: HashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>> = HashMap::new();
702        let mut token_nodes: HashSet<NodeIndex> = HashSet::new();
703        let mut component_ids: HashSet<ComponentId> = HashSet::new();
704        let mut visited_nodes = HashSet::new();
705        let mut queued_nodes = VecDeque::new();
706
707        visited_nodes.insert(token_in_node);
708        token_nodes.insert(token_in_node);
709        queued_nodes.push_back((token_in_node, 0usize));
710
711        while let Some((node, depth)) = queued_nodes.pop_front() {
712            if depth >= max_hops {
713                continue;
714            }
715            for edge in graph.edges(node) {
716                let target = edge.target();
717                let cid = edge.weight().component_id.clone();
718
719                adj.entry(node)
720                    .or_default()
721                    .push((target, cid.clone()));
722                component_ids.insert(cid);
723                token_nodes.insert(target);
724
725                if visited_nodes.insert(target) {
726                    queued_nodes.push_back((target, depth + 1));
727                }
728            }
729        }
730
731        if adj.is_empty() {
732            return Err(AlgorithmError::NoPath {
733                from: order.token_in().clone(),
734                to: order.token_out().clone(),
735                reason: NoPathReason::NoGraphPath,
736            });
737        }
738
739        Ok((adj, token_nodes, component_ids))
740    }
741
742    /// Computes net_amount_out by subtracting gas costs from the output amount.
743    ///
744    /// Uses the same resolution strategy as relaxation: direct token price lookup
745    /// first, then cumulative spot price product fallback for tokens not in the price
746    /// table.
747    #[allow(clippy::too_many_arguments)]
748    fn compute_net_amount_out(
749        amount_out: &BigUint,
750        route: &Route,
751        gas_price: &BigUint,
752        token_prices: Option<&TokenGasPrices>,
753        spot_product: &[f64],
754        node_address: &HashMap<NodeIndex, Address>,
755        token_in_node: NodeIndex,
756    ) -> Result<BigInt, AlgorithmError> {
757        let last_swap = route.swaps().last().ok_or_else(|| {
758            AlgorithmError::Other("compute_net_amount_out called with empty route".to_string())
759        })?;
760
761        let total_gas = route.total_gas();
762
763        if gas_price.is_zero() {
764            warn!("missing gas price, returning gross amount_out");
765            return Ok(BigInt::from(amount_out.clone()));
766        }
767
768        let gas_cost_wei = &total_gas * gas_price;
769
770        // Find the output token's node to get its spot_product for the fallback
771        let out_addr = last_swap.token_out();
772        let out_node_spot = node_address
773            .iter()
774            .find(|(_, addr)| *addr == out_addr)
775            .and_then(|(node, _)| spot_product.get(node.index()).copied())
776            .unwrap_or(0.0);
777
778        let output_price = Self::resolve_token_price(
779            Some(out_addr),
780            token_prices,
781            out_node_spot,
782            node_address.get(&token_in_node),
783        );
784
785        Ok(match output_price {
786            Some(price) if !price.denominator.is_zero() => {
787                let gas_cost = &gas_cost_wei * &price.numerator / &price.denominator;
788                BigInt::from(amount_out.clone()) - BigInt::from(gas_cost)
789            }
790            _ => {
791                warn!("no gas price for output token, returning gross amount_out");
792                BigInt::from(amount_out.clone())
793            }
794        })
795    }
796}
797
798impl Algorithm for BellmanFordAlgorithm {
799    type GraphType = StableDiGraph<()>;
800    type GraphManager = PetgraphStableDiGraphManager<()>;
801
802    fn name(&self) -> &str {
803        "bellman_ford"
804    }
805
806    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
807    async fn find_best_route(
808        &self,
809        graph: &Self::GraphType,
810        market: MarketData,
811        label: Option<StateLabel>,
812        derived: Option<SharedDerivedDataRef>,
813        order: &Order,
814    ) -> Result<RouteResult, AlgorithmError> {
815        let ctx = self
816            .build_context(graph, market, label, derived, order)
817            .await?;
818        self.find_single_route(&ctx, order, FindRouteOptions::default())
819    }
820
821    fn computation_requirements(&self) -> ComputationRequirements {
822        // Static requirements for independent computations; cannot conflict.
823        // The trait returns ComputationRequirements (not Result), so expect is
824        // the appropriate pattern for this infallible case.
825        ComputationRequirements::none()
826            .allow_stale("token_prices")
827            .expect("token_prices requirement conflicts (bug)")
828            .allow_stale("spot_prices")
829            .expect("spot_prices requirement conflicts (bug)")
830    }
831
832    fn timeout(&self) -> Duration {
833        self.timeout
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use std::sync::Arc;
840
841    use num_bigint::BigInt;
842    use tokio::sync::RwLock;
843    use tycho_simulation::{
844        tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim},
845        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
846    };
847
848    use super::*;
849    use crate::{
850        algorithm::test_utils::{component, order, token, MockProtocolSim},
851        derived::{types::TokenGasPrices, DerivedData},
852        feed::market_data::{MarketData, MarketState},
853        graph::GraphManager,
854        types::quote::OrderSide,
855    };
856
857    // ==================== Test Utilities ====================
858
859    /// Sets up market and graph with `()` edge weights for BellmanFord tests.
860    fn setup_market_bf(
861        pools: Vec<(&str, &Token, &Token, MockProtocolSim)>,
862    ) -> (MarketData, PetgraphStableDiGraphManager<()>) {
863        let mut market = MarketState::new();
864
865        market.update_gas_price(BlockGasPrice {
866            block_number: 1,
867            block_hash: Default::default(),
868            block_timestamp: 0,
869            pricing: GasPrice::Legacy { gas_price: BigUint::from(100u64) },
870        });
871        market.update_last_updated(crate::types::BlockInfo::new(1, "0x00".into(), 0));
872
873        for (pool_id, token_in, token_out, state) in pools {
874            let tokens = vec![token_in.clone(), token_out.clone()];
875            let comp = component(pool_id, &tokens);
876            market.upsert_components(std::iter::once(comp));
877            market.update_states([(pool_id.to_string(), Box::new(state) as Box<dyn ProtocolSim>)]);
878            market.upsert_tokens(tokens);
879        }
880
881        let mut graph_manager = PetgraphStableDiGraphManager::default();
882        graph_manager.initialize_graph(&market.component_topology());
883
884        (MarketData::new(Arc::new(RwLock::new(market))), graph_manager)
885    }
886
887    fn setup_derived_with_token_prices(
888        token_addresses: &[Address],
889    ) -> crate::derived::SharedDerivedDataRef {
890        use tycho_simulation::tycho_core::simulation::protocol_sim::Price;
891
892        let mut token_prices: TokenGasPrices = HashMap::new();
893        for address in token_addresses {
894            token_prices.insert(
895                address.clone(),
896                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
897            );
898        }
899
900        let mut derived_data = DerivedData::new();
901        derived_data.set_token_prices(token_prices, vec![], 1, true);
902        Arc::new(RwLock::new(derived_data))
903    }
904
905    fn bf_algorithm(max_hops: usize, timeout_ms: u64) -> BellmanFordAlgorithm {
906        BellmanFordAlgorithm::with_config(
907            AlgorithmConfig::new(1, max_hops, Duration::from_millis(timeout_ms), None).unwrap(),
908        )
909    }
910
911    // ==================== Unit Tests ====================
912
913    #[tokio::test]
914    async fn test_linear_path_found() {
915        let token_a = token(0x01, "A");
916        let token_b = token(0x02, "B");
917        let token_c = token(0x03, "C");
918        let token_d = token(0x04, "D");
919
920        let (market, manager) = setup_market_bf(vec![
921            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
922            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
923            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
924        ]);
925
926        let algo = bf_algorithm(4, 1000);
927        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
928
929        let result = algo
930            .find_best_route(manager.graph(), market, None, None, &ord)
931            .await
932            .unwrap();
933
934        assert_eq!(result.route().swaps().len(), 3);
935        // A->B: 100*2=200, B->C: 200*3=600, C->D: 600*4=2400
936        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
937        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
938        assert_eq!(result.route().swaps()[2].amount_out(), &BigUint::from(2400u64));
939    }
940
941    #[tokio::test]
942    async fn test_picks_better_of_two_paths() {
943        // Diamond graph: A->B->D (2*3=6x) vs A->C->D (4*1=4x)
944        let token_a = token(0x01, "A");
945        let token_b = token(0x02, "B");
946        let token_c = token(0x03, "C");
947        let token_d = token(0x04, "D");
948
949        let (market, manager) = setup_market_bf(vec![
950            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
951            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(3.0)),
952            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(4.0)),
953            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(1.0)),
954        ]);
955
956        let algo = bf_algorithm(3, 1000);
957        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
958
959        let result = algo
960            .find_best_route(manager.graph(), market, None, None, &ord)
961            .await
962            .unwrap();
963
964        // A->B->D: 100*2*3=600 is better than A->C->D: 100*4*1=400
965        assert_eq!(result.route().swaps().len(), 2);
966        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
967        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
968        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
969    }
970
971    #[tokio::test]
972    async fn test_parallel_pools() {
973        // Two pools between A and B with different multipliers
974        let token_a = token(0x01, "A");
975        let token_b = token(0x02, "B");
976
977        let (market, manager) = setup_market_bf(vec![
978            ("pool1", &token_a, &token_b, MockProtocolSim::new(2.0)),
979            ("pool2", &token_a, &token_b, MockProtocolSim::new(5.0)),
980        ]);
981
982        let algo = bf_algorithm(2, 1000);
983        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
984
985        let result = algo
986            .find_best_route(manager.graph(), market, None, None, &ord)
987            .await
988            .unwrap();
989
990        assert_eq!(result.route().swaps().len(), 1);
991        assert_eq!(result.route().swaps()[0].component_id(), "pool2");
992        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(500u64));
993    }
994
995    #[tokio::test]
996    async fn test_no_path_returns_error() {
997        let token_a = token(0x01, "A");
998        let token_b = token(0x02, "B");
999        let token_c = token(0x03, "C");
1000
1001        // A-B connected, C disconnected
1002        let (market, manager) =
1003            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1004
1005        // Add token_c to market without connecting it
1006        {
1007            let mut m = market.write().await;
1008            m.upsert_tokens(vec![token_c.clone()]);
1009        }
1010
1011        let algo = bf_algorithm(3, 1000);
1012        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1013
1014        let result = algo
1015            .find_best_route(manager.graph(), market, None, None, &ord)
1016            .await;
1017        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1018    }
1019
1020    #[tokio::test]
1021    async fn test_source_not_in_graph() {
1022        let token_a = token(0x01, "A");
1023        let token_b = token(0x02, "B");
1024        let token_x = token(0x99, "X");
1025
1026        let (market, manager) =
1027            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1028
1029        let algo = bf_algorithm(3, 1000);
1030        let ord = order(&token_x, &token_b, 100, OrderSide::Sell);
1031
1032        let result = algo
1033            .find_best_route(manager.graph(), market, None, None, &ord)
1034            .await;
1035        assert!(matches!(
1036            result,
1037            Err(AlgorithmError::NoPath { reason: NoPathReason::SourceTokenNotInGraph, .. })
1038        ));
1039    }
1040
1041    #[tokio::test]
1042    async fn test_destination_not_in_graph() {
1043        let token_a = token(0x01, "A");
1044        let token_b = token(0x02, "B");
1045        let token_x = token(0x99, "X");
1046
1047        let (market, manager) =
1048            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1049
1050        let algo = bf_algorithm(3, 1000);
1051        let ord = order(&token_a, &token_x, 100, OrderSide::Sell);
1052
1053        let result = algo
1054            .find_best_route(manager.graph(), market, None, None, &ord)
1055            .await;
1056        assert!(matches!(
1057            result,
1058            Err(AlgorithmError::NoPath { reason: NoPathReason::DestinationTokenNotInGraph, .. })
1059        ));
1060    }
1061
1062    #[tokio::test]
1063    async fn test_respects_max_hops() {
1064        // Path A->B->C->D exists but requires 3 hops; max_hops=2
1065        let token_a = token(0x01, "A");
1066        let token_b = token(0x02, "B");
1067        let token_c = token(0x03, "C");
1068        let token_d = token(0x04, "D");
1069
1070        let (market, manager) = setup_market_bf(vec![
1071            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1072            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1073            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
1074        ]);
1075
1076        let algo = bf_algorithm(2, 1000);
1077        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1078
1079        let result = algo
1080            .find_best_route(manager.graph(), market, None, None, &ord)
1081            .await;
1082        assert!(
1083            matches!(result, Err(AlgorithmError::NoPath { .. })),
1084            "Should not find 3-hop path with max_hops=2"
1085        );
1086    }
1087
1088    #[tokio::test]
1089    async fn test_source_token_revisit_blocked() {
1090        // Forbid-revisits prevents paths like A->B->A->B->C. The algorithm
1091        // should find the direct A->B->C path instead.
1092        let token_a = token(0x01, "A");
1093        let token_b = token(0x02, "B");
1094        let token_c = token(0x03, "C");
1095
1096        let (market, manager) = setup_market_bf(vec![
1097            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1098            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1099        ]);
1100
1101        let algo = bf_algorithm(4, 1000);
1102        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1103
1104        let result = algo
1105            .find_best_route(manager.graph(), market, None, None, &ord)
1106            .await
1107            .unwrap();
1108
1109        // Should find exactly the 2-hop path A->B->C = 100*2*3 = 600
1110        assert_eq!(result.route().swaps().len(), 2);
1111        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
1112        assert_eq!(result.route().swaps()[1].component_id(), "pool_bc");
1113        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1114    }
1115
1116    #[tokio::test]
1117    async fn test_hub_token_revisit_blocked() {
1118        // Forbid-revisits blocks A->B->C->B->D (B visited twice).
1119        // The algorithm should find the direct A->B->D = 400 instead.
1120        let token_a = token(0x01, "A");
1121        let token_c = token(0x02, "C");
1122        let token_b = token(0x03, "B");
1123        let token_d = token(0x04, "D");
1124
1125        let (market, manager) = setup_market_bf(vec![
1126            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1127            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1128            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(100.0)),
1129            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0)),
1130        ]);
1131
1132        let algo = bf_algorithm(4, 1000);
1133        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1134
1135        let result = algo
1136            .find_best_route(manager.graph(), market, None, None, &ord)
1137            .await
1138            .unwrap();
1139
1140        // Should find A->B->D = 100*2*2 = 400 (the direct 2-hop path)
1141        // The 4-hop revisit path A->B->C->B->D is blocked
1142        assert_eq!(result.route().swaps().len(), 2, "should use direct 2-hop path");
1143        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
1144        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
1145        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(400u64));
1146    }
1147
1148    #[tokio::test]
1149    async fn test_route_amounts_are_sequential() {
1150        // Verify that swap amount_in[i+1] == amount_out[i] in the built route
1151        let token_a = token(0x01, "A");
1152        let token_b = token(0x02, "B");
1153        let token_c = token(0x03, "C");
1154
1155        let (market, manager) = setup_market_bf(vec![
1156            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1157            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1158        ]);
1159
1160        let algo = bf_algorithm(3, 1000);
1161        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1162
1163        let result = algo
1164            .find_best_route(manager.graph(), market, None, None, &ord)
1165            .await
1166            .unwrap();
1167
1168        assert_eq!(result.route().swaps().len(), 2);
1169        // amount_in of second swap == amount_out of first swap
1170        assert_eq!(result.route().swaps()[1].amount_in(), result.route().swaps()[0].amount_out());
1171    }
1172
1173    #[tokio::test]
1174    async fn test_gas_deduction() {
1175        let token_a = token(0x01, "A");
1176        let token_b = token(0x02, "B");
1177
1178        let (market, manager) = setup_market_bf(vec![(
1179            "pool1",
1180            &token_a,
1181            &token_b,
1182            MockProtocolSim::new(2.0).with_gas(10),
1183        )]);
1184
1185        let algo = bf_algorithm(2, 1000);
1186        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1187
1188        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1189
1190        let result = algo
1191            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1192            .await
1193            .unwrap();
1194
1195        // Output: 1000 * 2 = 2000
1196        // Gas: 10 gas units * 100 gas_price = 1000 wei * 1/1 price = 1000
1197        // Net: 2000 - 1000 = 1000
1198        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
1199        assert_eq!(result.net_amount_out(), &BigInt::from(1000));
1200    }
1201
1202    #[tokio::test]
1203    async fn test_timeout_respected() {
1204        let token_a = token(0x01, "A");
1205        let token_b = token(0x02, "B");
1206        let token_c = token(0x03, "C");
1207
1208        let (market, manager) = setup_market_bf(vec![
1209            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1210            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1211        ]);
1212
1213        // 0ms timeout
1214        let algo = bf_algorithm(3, 0);
1215        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1216
1217        let result = algo
1218            .find_best_route(manager.graph(), market, None, None, &ord)
1219            .await;
1220
1221        // With 0ms timeout, we expect either:
1222        // - A partial result (if some layers completed before timeout check)
1223        // - Timeout error
1224        // - NoPath (if timeout prevented completing enough layers to reach dest)
1225        match result {
1226            Ok(r) => {
1227                assert!(!r.route().swaps().is_empty());
1228            }
1229            Err(AlgorithmError::Timeout { .. }) | Err(AlgorithmError::NoPath { .. }) => {
1230                // Both are acceptable for 0ms timeout
1231            }
1232            Err(e) => panic!("Unexpected error: {:?}", e),
1233        }
1234    }
1235
1236    // ==================== Integration-style Tests ====================
1237
1238    #[tokio::test]
1239    async fn test_with_fees() {
1240        let token_a = token(0x01, "A");
1241        let token_b = token(0x02, "B");
1242
1243        // Pool with 10% fee
1244        let (market, manager) = setup_market_bf(vec![(
1245            "pool1",
1246            &token_a,
1247            &token_b,
1248            MockProtocolSim::new(2.0).with_fee(0.1),
1249        )]);
1250
1251        let algo = bf_algorithm(2, 1000);
1252        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1253
1254        let result = algo
1255            .find_best_route(manager.graph(), market, None, None, &ord)
1256            .await
1257            .unwrap();
1258
1259        // 1000 * 2 * (1-0.1) = 1800
1260        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(1800u64));
1261    }
1262
1263    #[tokio::test]
1264    async fn test_large_trade_slippage() {
1265        let token_a = token(0x01, "A");
1266        let token_b = token(0x02, "B");
1267
1268        // Pool with limited liquidity (500 tokens)
1269        let (market, manager) = setup_market_bf(vec![(
1270            "pool1",
1271            &token_a,
1272            &token_b,
1273            MockProtocolSim::new(2.0).with_liquidity(500),
1274        )]);
1275
1276        let algo = bf_algorithm(2, 1000);
1277        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1278
1279        // Should fail due to insufficient liquidity
1280        let result = algo
1281            .find_best_route(manager.graph(), market, None, None, &ord)
1282            .await;
1283        assert!(
1284            matches!(result, Err(AlgorithmError::NoPath { .. })),
1285            "Should fail when trade exceeds pool liquidity"
1286        );
1287    }
1288
1289    #[tokio::test]
1290    async fn test_disconnected_tokens_return_no_path() {
1291        // A-B connected, D-E disconnected. Routing A->E should fail.
1292        let token_a = token(0x01, "A");
1293        let token_b = token(0x02, "B");
1294        let token_d = token(0x04, "D");
1295        let token_e = token(0x05, "E");
1296
1297        let (market, manager) = setup_market_bf(vec![
1298            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1299            ("pool_de", &token_d, &token_e, MockProtocolSim::new(4.0)),
1300        ]);
1301
1302        let algo = bf_algorithm(3, 1000);
1303        let ord = order(&token_a, &token_e, 100, OrderSide::Sell);
1304
1305        let result = algo
1306            .find_best_route(manager.graph(), market, None, None, &ord)
1307            .await;
1308        assert!(
1309            matches!(result, Err(AlgorithmError::NoPath { .. })),
1310            "should not find path to disconnected component"
1311        );
1312    }
1313
1314    #[tokio::test]
1315    async fn test_spfa_skips_failed_simulations() {
1316        // Pool that will fail simulation (liquidity=0 would cause error for any amount)
1317        let token_a = token(0x01, "A");
1318        let token_b = token(0x02, "B");
1319        let token_c = token(0x03, "C");
1320
1321        let (market, manager) = setup_market_bf(vec![
1322            // Direct path with failing pool
1323            ("pool_ab_bad", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(0)),
1324            // Alternative path that works
1325            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0)),
1326            ("pool_cb", &token_c, &token_b, MockProtocolSim::new(3.0)),
1327        ]);
1328
1329        let algo = bf_algorithm(3, 1000);
1330        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1331
1332        let result = algo
1333            .find_best_route(manager.graph(), market, None, None, &ord)
1334            .await;
1335
1336        // Should find A->C->B despite A->B failing
1337        // Note: MockProtocolSim with liquidity=0 will fail for amount > 0
1338        // The direct A->B edge should be skipped and the 2-hop path used
1339        match result {
1340            Ok(r) => {
1341                // Found alternative path
1342                assert!(!r.route().swaps().is_empty());
1343            }
1344            Err(AlgorithmError::NoPath { .. }) => {
1345                // Also acceptable if liquidity=0 blocks all paths through B
1346                // (since the failing pool might also block the reverse B->A edge)
1347            }
1348            Err(e) => panic!("Unexpected error: {:?}", e),
1349        }
1350    }
1351
1352    #[tokio::test]
1353    async fn test_resimulation_produces_correct_amounts() {
1354        // Verifies that re-simulation produces the same correct sequential amounts
1355        let token_a = token(0x01, "A");
1356        let token_b = token(0x02, "B");
1357        let token_c = token(0x03, "C");
1358
1359        let (market, manager) = setup_market_bf(vec![
1360            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1361            ("pool_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1362        ]);
1363
1364        let algo = bf_algorithm(3, 1000);
1365        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1366
1367        let result = algo
1368            .find_best_route(manager.graph(), market, None, None, &ord)
1369            .await
1370            .unwrap();
1371
1372        // Verify the final amounts are from re-simulation, not relaxation
1373        // A->B: 100*2=200, B->C: 200*3=600
1374        assert_eq!(result.route().swaps()[0].amount_in(), &BigUint::from(100u64));
1375        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1376        assert_eq!(result.route().swaps()[1].amount_in(), &BigUint::from(200u64));
1377        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1378    }
1379
1380    // ==================== Trait getter tests ====================
1381
1382    #[test]
1383    fn algorithm_name() {
1384        let algo = bf_algorithm(4, 200);
1385        assert_eq!(algo.name(), "bellman_ford");
1386    }
1387
1388    #[test]
1389    fn algorithm_timeout() {
1390        let algo = bf_algorithm(4, 200);
1391        assert_eq!(algo.timeout(), Duration::from_millis(200));
1392    }
1393
1394    // ==================== Forbid-revisit helper tests ====================
1395
1396    #[tokio::test]
1397    async fn test_gas_aware_relaxation_picks_cheaper_path() {
1398        // Diamond graph: A -> B -> D vs A -> C -> D
1399        // Path via B: higher gross output (3x * 2x = 6x) but extreme gas (100M per hop)
1400        // Path via C: lower gross output (2x * 2x = 4x) but cheap gas (100 per hop)
1401        //
1402        // With gas_price=100, token_prices[D]=1:1 for WETH conversion:
1403        // Path B gas cost: (100M + 100M) * 100 * 1 = 20B
1404        // Path C gas cost: (100 + 100) * 100 * 1 = 20K
1405        //
1406        // For an input of 1B:
1407        // Path B: gross = 6B, net = 6B - 20B = -14B
1408        // Path C: gross = 4B, net = 4B - 20K ≈ 4B
1409        //
1410        // Without gas awareness: Path B wins (6B > 4B)
1411        // With gas awareness: Path C wins (4B net > -14B net)
1412        let token_a = token(0x01, "A");
1413        let token_b = token(0x02, "B");
1414        let token_c = token(0x03, "C");
1415        let token_d = token(0x04, "D");
1416
1417        let high_gas: u64 = 100_000_000;
1418        let low_gas: u64 = 100;
1419
1420        let (market, manager) = setup_market_bf(vec![
1421            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1422            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1423            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1424            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1425        ]);
1426
1427        let algo = bf_algorithm(3, 1000);
1428        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1429
1430        // With gas-aware relaxation (derived data with token prices + gas price in market)
1431        let derived = setup_derived_with_token_prices(&[
1432            token_a.address.clone(),
1433            token_b.address.clone(),
1434            token_c.address.clone(),
1435            token_d.address.clone(),
1436        ]);
1437
1438        let result = algo
1439            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1440            .await
1441            .unwrap();
1442
1443        // Gas-aware relaxation should pick the cheaper path A -> C -> D
1444        assert_eq!(result.route().swaps().len(), 2);
1445        assert_eq!(result.route().swaps()[0].component_id(), "pool_ac");
1446        assert_eq!(result.route().swaps()[1].component_id(), "pool_cd");
1447    }
1448
1449    #[tokio::test]
1450    async fn test_gas_aware_falls_back_to_gross_without_derived() {
1451        // Same diamond graph as above, but without derived data.
1452        // Should fall back to gross comparison and pick Path B (higher gross).
1453        let token_a = token(0x01, "A");
1454        let token_b = token(0x02, "B");
1455        let token_c = token(0x03, "C");
1456        let token_d = token(0x04, "D");
1457
1458        let high_gas: u64 = 100_000_000;
1459        let low_gas: u64 = 100;
1460
1461        let (market, manager) = setup_market_bf(vec![
1462            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1463            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1464            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1465            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1466        ]);
1467
1468        let algo = bf_algorithm(3, 1000);
1469        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1470
1471        // No derived data: should fall back to gross comparison
1472        let result = algo
1473            .find_best_route(manager.graph(), market, None, None, &ord)
1474            .await
1475            .unwrap();
1476
1477        // Without gas awareness, picks the higher-gross path A -> B -> D
1478        assert_eq!(result.route().swaps().len(), 2);
1479        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
1480        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
1481    }
1482
1483    // ==================== Connector token tests ====================
1484
1485    /// Build a BellmanFord algorithm whose config includes a specific connector token allowlist.
1486    fn bf_algorithm_with_connectors(
1487        max_hops: usize,
1488        timeout_ms: u64,
1489        connector_tokens: HashSet<Address>,
1490    ) -> BellmanFordAlgorithm {
1491        BellmanFordAlgorithm::with_config(
1492            AlgorithmConfig::new(1, max_hops, Duration::from_millis(timeout_ms), None)
1493                .unwrap()
1494                .with_connector_tokens(connector_tokens),
1495        )
1496    }
1497
1498    #[tokio::test]
1499    async fn test_connector_tokens_blocks_disallowed_intermediate() {
1500        //      A
1501        //    /   \
1502        //   B     C   ← only C is in the allowlist
1503        //    \   /
1504        //      D
1505        // A->B->D is pruned; only A->C->D survives.
1506        let token_a = token(0x01, "A");
1507        let token_b = token(0x02, "B");
1508        let token_c = token(0x03, "C");
1509        let token_d = token(0x04, "D");
1510
1511        let (market, manager) = setup_market_bf(vec![
1512            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1513            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(2.0)),
1514            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(3.0)),
1515            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(3.0)),
1516        ]);
1517
1518        let connectors: HashSet<Address> = [token_c.address.clone()].into();
1519        let algo = bf_algorithm_with_connectors(3, 1000, connectors);
1520        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1521
1522        let result = algo
1523            .find_best_route(manager.graph(), market, None, None, &ord)
1524            .await
1525            .unwrap();
1526
1527        // Only A->C->D is reachable; B was pruned.
1528        assert_eq!(result.route().swaps().len(), 2);
1529        assert_eq!(result.route().swaps()[0].component_id(), "pool_ac");
1530        assert_eq!(result.route().swaps()[1].component_id(), "pool_cd");
1531    }
1532
1533    #[tokio::test]
1534    async fn test_connector_tokens_allows_endpoints_even_if_not_listed() {
1535        // token_in (A) and token_out (B) must be reachable even when connector list is empty.
1536        let token_a = token(0x01, "A");
1537        let token_b = token(0x02, "B");
1538
1539        let (market, manager) =
1540            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1541
1542        // Empty allowlist — no intermediate tokens allowed, but direct hop A->B should work.
1543        let algo = bf_algorithm_with_connectors(1, 1000, HashSet::new());
1544        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1545
1546        let result = algo
1547            .find_best_route(manager.graph(), market, None, None, &ord)
1548            .await
1549            .unwrap();
1550
1551        assert_eq!(result.route().swaps().len(), 1);
1552        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1553    }
1554
1555    #[tokio::test]
1556    async fn test_connector_tokens_none_is_unrestricted() {
1557        // No connector_tokens set: both A->B->D and A->C->D are evaluated.
1558        let token_a = token(0x01, "A");
1559        let token_b = token(0x02, "B");
1560        let token_c = token(0x03, "C");
1561        let token_d = token(0x04, "D");
1562
1563        let (market, manager) = setup_market_bf(vec![
1564            ("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1565            ("pool_bd", &token_b, &token_d, MockProtocolSim::new(3.0)),
1566            ("pool_ac", &token_a, &token_c, MockProtocolSim::new(1.0)),
1567            ("pool_cd", &token_c, &token_d, MockProtocolSim::new(1.0)),
1568        ]);
1569
1570        let algo = bf_algorithm(3, 1000);
1571        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1572
1573        let result = algo
1574            .find_best_route(manager.graph(), market, None, None, &ord)
1575            .await
1576            .unwrap();
1577
1578        // Best path is A->B->D = 100*2*3 = 600
1579        assert_eq!(result.route().swaps()[0].component_id(), "pool_ab");
1580        assert_eq!(result.route().swaps()[1].component_id(), "pool_bd");
1581        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1582    }
1583
1584    #[test]
1585    fn test_path_has_conflict_detects_node_and_pool() {
1586        // Path: 0 -[pool_a]-> 1 -[pool_b]-> 2
1587        let mut pred: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; 4];
1588        pred[1] = Some((NodeIndex::new(0), "pool_a".into()));
1589        pred[2] = Some((NodeIndex::new(1), "pool_b".into()));
1590
1591        // Node conflicts: node 0 is in path, node 3 is not
1592        assert!(BellmanFordAlgorithm::path_has_conflict(
1593            NodeIndex::new(2),
1594            NodeIndex::new(0),
1595            &"any".into(),
1596            &pred
1597        ));
1598        assert!(!BellmanFordAlgorithm::path_has_conflict(
1599            NodeIndex::new(2),
1600            NodeIndex::new(3),
1601            &"any".into(),
1602            &pred
1603        ));
1604        // Self-check: node 2 is itself in the "path from 2"
1605        assert!(BellmanFordAlgorithm::path_has_conflict(
1606            NodeIndex::new(2),
1607            NodeIndex::new(2),
1608            &"any".into(),
1609            &pred
1610        ));
1611
1612        // Pool conflicts: pool_a and pool_b are used, pool_c is not
1613        assert!(BellmanFordAlgorithm::path_has_conflict(
1614            NodeIndex::new(2),
1615            NodeIndex::new(3),
1616            &"pool_a".into(),
1617            &pred
1618        ));
1619        assert!(BellmanFordAlgorithm::path_has_conflict(
1620            NodeIndex::new(2),
1621            NodeIndex::new(3),
1622            &"pool_b".into(),
1623            &pred
1624        ));
1625        assert!(!BellmanFordAlgorithm::path_has_conflict(
1626            NodeIndex::new(2),
1627            NodeIndex::new(3),
1628            &"pool_c".into(),
1629            &pred
1630        ));
1631    }
1632
1633    #[tokio::test]
1634    async fn test_find_single_route_with_state_overrides() {
1635        let token_a = token(0x01, "A");
1636        let token_b = token(0x02, "B");
1637
1638        let (market, manager) =
1639            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1640
1641        let algo = bf_algorithm(2, 1000);
1642        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1643
1644        let ctx = algo
1645            .build_context(manager.graph(), market, None, None, &ord)
1646            .await
1647            .unwrap();
1648
1649        // Without overrides: 1000 * 2.0 = 2000
1650        let normal = algo
1651            .find_single_route(&ctx, &ord, FindRouteOptions::default())
1652            .unwrap();
1653        assert_eq!(normal.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
1654
1655        // Override pool_ab with a degraded sim (multiplier 1.0): 1000 * 1.0 = 1000
1656        let opts = FindRouteOptions {
1657            overrides: MarketOverrides::empty()
1658                .with_override("pool_ab".to_string(), Box::new(MockProtocolSim::new(1.0))),
1659        };
1660        let overridden = algo
1661            .find_single_route(&ctx, &ord, opts)
1662            .unwrap();
1663        assert_eq!(overridden.route().swaps()[0].amount_out(), &BigUint::from(1000u64));
1664
1665        assert!(
1666            overridden.route().swaps()[0].amount_out() < normal.route().swaps()[0].amount_out()
1667        );
1668    }
1669
1670    #[tokio::test]
1671    async fn test_single_find_route_options_default() {
1672        use super::super::split_primitives::MarketOverrides;
1673
1674        let token_a = token(0x01, "A");
1675        let token_b = token(0x02, "B");
1676
1677        let (market, manager) =
1678            setup_market_bf(vec![("pool_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1679
1680        let algo = bf_algorithm(2, 1000);
1681        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1682
1683        let ctx = algo
1684            .build_context(manager.graph(), market, None, None, &ord)
1685            .await
1686            .unwrap();
1687
1688        let with_default = algo
1689            .find_single_route(&ctx, &ord, FindRouteOptions::default())
1690            .unwrap();
1691        let with_empty = algo
1692            .find_single_route(&ctx, &ord, FindRouteOptions { overrides: MarketOverrides::empty() })
1693            .unwrap();
1694
1695        assert_eq!(
1696            with_default.route().swaps()[0].amount_out(),
1697            with_empty.route().swaps()[0].amount_out()
1698        );
1699        assert_eq!(with_default.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
1700    }
1701}