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