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 component simulations (`get_amount_out()`) during edge relaxation to find
4//! optimal A-to-B routes that account for slippage, fees, and component 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 component 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::VecDeque,
28    sync::Arc,
29    time::{Duration, Instant},
30};
31
32use num_bigint::{BigInt, BigUint};
33use num_traits::{ToPrimitive, Zero};
34use petgraph::{graph::NodeIndex, prelude::EdgeRef};
35use rustc_hash::{FxHashMap, FxHashSet};
36use tracing::{debug, instrument, trace, warn};
37use tycho_simulation::{
38    tycho_common::models::Address,
39    tycho_core::{models::token::Token, simulation::protocol_sim::Price},
40};
41
42use super::{
43    split_primitives::MarketOverrides, Algorithm, AlgorithmConfig, AlgorithmError, NoPathReason,
44};
45use crate::{
46    algorithm::{paths, sim_guard::GuardedProtocolSim},
47    derived::{
48        computation::ComputationRequirements,
49        types::{SpotPrices, TokenGasPrices},
50        SharedDerivedDataRef,
51    },
52    feed::market_data::{MarketData, MarketState, StateLabel},
53    graph::{petgraph::StableDiGraph, PetgraphStableDiGraphManager},
54    types::{ComponentId, Order, Route, RouteResult, Swap},
55};
56
57/// BFS subgraph: adjacency list, token node set, and component ID set.
58///
59/// The component set borrows from the graph. It exists to ask for the market subset and is done
60/// with before the solve starts, unlike the adjacency list, which outlives the graph borrow inside
61/// [`BellmanFordContext`] and so owns its ids.
62type Subgraph<'a> = (
63    FxHashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>>,
64    FxHashSet<NodeIndex>,
65    FxHashSet<&'a ComponentId>,
66);
67
68/// Everything needed to call `find_single_route` repeatedly without redoing setup.
69///
70/// Built once by `build_context`, which acquires the market and derived locks and snapshots all
71/// relevant states. `find_single_route` uses this snapshot directly — no lock re-acquisition — so
72/// all route evaluations within one order see a consistent view of the same block's component
73/// states.
74pub(crate) struct BellmanFordContext {
75    pub(crate) token_in_node: NodeIndex,
76    pub(crate) token_out_node: NodeIndex,
77    pub(crate) adj: FxHashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>>,
78    pub(crate) token_map: FxHashMap<NodeIndex, Arc<Token>>,
79    pub(crate) market_data: MarketState,
80    pub(crate) gas_price_wei: Option<BigUint>,
81    pub(crate) token_prices: Option<TokenGasPrices>,
82    pub(crate) spot_prices: Option<SpotPrices>,
83    pub(crate) node_address: FxHashMap<NodeIndex, Address>,
84    pub(crate) max_idx: usize,
85    pub(crate) scoring: RouteScoringMode,
86}
87
88/// Controls how `find_single_route` ranks candidate routes after simulation.
89pub(crate) enum RouteScoringMode {
90    /// Rank by gross output (ignore gas cost). Used when the caller accounts for gas externally.
91    GrossOutput,
92    /// Rank by net output (gross output minus gas cost in output token units). Default.
93    NetOutput,
94}
95
96/// Per-call overrides for `find_single_route`.
97#[derive(Default)]
98pub(crate) struct FindRouteOptions {
99    /// Component state overrides: degrade or zero-gas specific components without modifying market
100    /// data.
101    pub(crate) overrides: MarketOverrides,
102}
103
104/// Output of the SPFA relaxation pass: per-node best-path arrays.
105struct SPFAResult {
106    /// Best gross output amount reachable at each node index.
107    amount: Vec<BigUint>,
108    /// The (predecessor node, component) that last improved each node's amount.
109    predecessor: Vec<Option<(NodeIndex, ComponentId)>>,
110    /// Gas consumed by the edge that last improved each node's amount.
111    edge_gas: Vec<BigUint>,
112    /// Cumulative spot-price product from token_in to each node (for gas fallback).
113    spot_product: Vec<f64>,
114    /// True if some hop's input couldn't cover that hop's own gas (gas-aware) or a sim
115    /// produced a literal zero output (gas-unaware) — i.e. the amount is dust, not unroutable.
116    input_below_hop_gas: bool,
117}
118
119/// Bellman-Ford algorithm with SPFA optimisation for simulation-driven DEX routing.
120///
121/// Finds optimal A→B routes by running actual component simulations during edge relaxation,
122/// accounting for slippage, fees, and component mechanics at the requested trade size.
123/// Gas costs are subtracted when price data is available.
124pub struct BellmanFordAlgorithm {
125    max_hops: usize,
126    timeout: Duration,
127    gas_aware: bool,
128    connector_tokens: Option<FxHashSet<Address>>,
129}
130
131impl Default for BellmanFordAlgorithm {
132    fn default() -> Self {
133        Self::with_config(AlgorithmConfig::default())
134    }
135}
136
137impl BellmanFordAlgorithm {
138    pub(crate) fn with_config(config: AlgorithmConfig) -> Self {
139        Self {
140            max_hops: config.max_hops(),
141            timeout: config.timeout(),
142            gas_aware: config.gas_aware(),
143            connector_tokens: config.connector_tokens().cloned(),
144        }
145    }
146
147    /// One-time async setup for repeated `find_single_route` calls.
148    ///
149    /// Validates the order, extracts the subgraph, acquires the market and derived data
150    /// locks exactly once, and snapshots all state into a [`BellmanFordContext`]. All
151    /// subsequent `find_single_route` calls on the returned context use the same block's
152    /// component states.
153    pub(crate) async fn build_context(
154        &self,
155        graph: &StableDiGraph<()>,
156        market: MarketData,
157        label: Option<StateLabel>,
158        derived: Option<SharedDerivedDataRef>,
159        order: &Order,
160    ) -> Result<BellmanFordContext, AlgorithmError> {
161        if !order.is_sell() {
162            return Err(AlgorithmError::ExactOutNotSupported);
163        }
164
165        let (token_prices, spot_prices) = if let Some(ref d) = derived {
166            let guard = d.read().await;
167            (guard.token_prices().cloned(), guard.spot_prices().cloned())
168        } else {
169            (None, None)
170        };
171
172        let token_in_node = graph
173            .node_indices()
174            .find(|&n| &graph[n] == order.token_in())
175            .ok_or(AlgorithmError::NoPath {
176                from: order.token_in().clone(),
177                to: order.token_out().clone(),
178                reason: NoPathReason::SourceTokenNotInGraph,
179            })?;
180        let token_out_node = graph
181            .node_indices()
182            .find(|&n| &graph[n] == order.token_out())
183            .ok_or(AlgorithmError::NoPath {
184                from: order.token_in().clone(),
185                to: order.token_out().clone(),
186                reason: NoPathReason::DestinationTokenNotInGraph,
187            })?;
188
189        if token_in_node == token_out_node {
190            return Err(AlgorithmError::NoPath {
191                from: order.token_in().clone(),
192                to: order.token_out().clone(),
193                reason: NoPathReason::NoGraphPath,
194            });
195        }
196
197        // Bounded from both ends, so the adjacency and component set cover only what could carry a
198        // route rather than everything within reach of the source.
199        let (adj, token_nodes, component_ids) =
200            Self::get_subgraph(graph, token_in_node, token_out_node, self.max_hops).ok_or_else(
201                || AlgorithmError::NoPath {
202                    from: order.token_in().clone(),
203                    to: order.token_out().clone(),
204                    reason: NoPathReason::NoGraphPath,
205                },
206            )?;
207
208        let market_view = paths::read_market(&market, label).await?;
209        let token_map: FxHashMap<NodeIndex, Arc<Token>> = token_nodes
210            .iter()
211            .filter_map(|&node| {
212                market_view
213                    .get_token_shared(&graph[node])
214                    .map(|token| (node, Arc::clone(token)))
215            })
216            .collect();
217        let market_data = market_view.extract_subset_with_overlay(&component_ids);
218        let gas_price_wei = market_data
219            .gas_price()
220            .map(|gp| gp.effective_gas_price().clone());
221        drop(market_view);
222
223        let node_address: FxHashMap<NodeIndex, Address> = token_map
224            .iter()
225            .map(|(&node, token)| (node, token.address.clone()))
226            .collect();
227
228        let max_idx = graph
229            .node_indices()
230            .map(|n| n.index())
231            .max()
232            .unwrap_or(0) +
233            1;
234
235        let scoring = if self.gas_aware {
236            RouteScoringMode::NetOutput
237        } else {
238            RouteScoringMode::GrossOutput
239        };
240
241        debug!(
242            edges = adj
243                .values()
244                .map(Vec::len)
245                .sum::<usize>(),
246            tokens = token_map.len(),
247            "subgraph extracted"
248        );
249
250        Ok(BellmanFordContext {
251            token_in_node,
252            token_out_node,
253            adj,
254            token_map,
255            market_data,
256            gas_price_wei,
257            token_prices,
258            spot_prices,
259            node_address,
260            max_idx,
261            scoring,
262        })
263    }
264
265    /// Runs the SPFA relaxation loop and reconstructs the best route from a pre-built context.
266    ///
267    /// This is the repeatable, synchronous solve phase. Call it multiple times with different
268    /// `opts.overrides` to evaluate alternative component states without redoing the setup in
269    /// `ctx`. Overrides shadow the corresponding component in `ctx.market_data` for both
270    /// relaxation and route construction.
271    pub(crate) fn find_single_route(
272        &self,
273        ctx: &BellmanFordContext,
274        order: &Order,
275        opts: FindRouteOptions,
276    ) -> Result<RouteResult, AlgorithmError> {
277        let start = Instant::now();
278
279        let spfa = self.run_spfa(ctx, order, &opts.overrides, start);
280
281        let out_idx = ctx.token_out_node.index();
282        if spfa.amount[out_idx].is_zero() {
283            // Dust (a hop's input below its own gas) -> AmountTooSmall; everything else
284            // (unreachable, filtered, sim error incl. too-large, missing state, timeout)
285            // -> NoGraphPath.
286            let reason = if spfa.input_below_hop_gas {
287                NoPathReason::AmountTooSmall
288            } else {
289                NoPathReason::NoGraphPath
290            };
291            return Err(AlgorithmError::NoPath {
292                from: order.token_in().clone(),
293                to: order.token_out().clone(),
294                reason,
295            });
296        }
297
298        // Reconstruct path and build route directly from stored distances/gas
299        // (no re-simulation needed since forbid-revisits guarantees relaxation
300        // amounts match sequential execution).
301        let path_edges =
302            Self::reconstruct_path(ctx.token_out_node, ctx.token_in_node, &spfa.predecessor)?;
303
304        let route =
305            Self::build_route(ctx, &path_edges, &spfa.amount, &spfa.edge_gas, &opts.overrides)?;
306
307        let final_amount_out = spfa.amount[out_idx].clone();
308        let gas_price = ctx
309            .gas_price_wei
310            .clone()
311            .unwrap_or_default();
312
313        let net_amount_out = Self::compute_net_amount_out(
314            &final_amount_out,
315            &route,
316            &gas_price,
317            ctx.token_prices.as_ref(),
318            &spfa.spot_product,
319            &ctx.node_address,
320            ctx.token_in_node,
321        )?;
322
323        let result = RouteResult::new(route, net_amount_out, gas_price);
324
325        let solve_time_ms = start.elapsed().as_millis() as u64;
326        debug!(
327            solve_time_ms,
328            hops = result.route().swaps().len(),
329            amount_in = %order.amount(),
330            amount_out = %final_amount_out,
331            net_amount_out = %result.net_amount_out(),
332            "bellman_ford route found"
333        );
334
335        Ok(result)
336    }
337
338    /// Runs SPFA (Shortest Path Faster Algorithm) relaxation over the subgraph and returns per-node
339    /// best-path arrays.
340    ///
341    /// Simulation failures are silently skipped (the edge is dropped). Returns the arrays
342    /// even if the destination was not reached — callers check `amount[out_idx].is_zero()`.
343    fn run_spfa(
344        &self,
345        ctx: &BellmanFordContext,
346        order: &Order,
347        overrides: &MarketOverrides,
348        start: Instant,
349    ) -> SPFAResult {
350        // amount[node] = best gross output reachable at that node.
351        // edge_gas[node] = gas for the edge that last improved amount[node].
352        // cumul_gas[node] = total gas along the best path to this node.
353        let mut amount: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
354        let mut predecessor: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; ctx.max_idx];
355        let mut edge_gas: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
356        let mut cumul_gas: Vec<BigUint> = vec![BigUint::ZERO; ctx.max_idx];
357
358        amount[ctx.token_in_node.index()] = order.amount().clone();
359
360        // Track cumulative spot price product from token_in for fallback gas estimation.
361        // spot_product[v] = product of spot prices along the path from token_in to v.
362        let mut spot_product: Vec<f64> = vec![0.0; ctx.max_idx];
363        spot_product[ctx.token_in_node.index()] = 1.0;
364
365        let mut input_below_hop_gas = false;
366
367        let gas_aware = matches!(ctx.scoring, RouteScoringMode::NetOutput) &&
368            ctx.gas_price_wei.is_some() &&
369            ctx.token_prices.is_some();
370        if !gas_aware && matches!(ctx.scoring, RouteScoringMode::NetOutput) {
371            debug!("gas-aware comparison disabled (missing gas_price or token_prices)");
372        } else if matches!(ctx.scoring, RouteScoringMode::GrossOutput) {
373            debug!("gas-aware comparison disabled by config");
374        }
375
376        let mut active_nodes: Vec<NodeIndex> = vec![ctx.token_in_node];
377
378        for round in 0..self.max_hops {
379            if start.elapsed() >= self.timeout {
380                debug!(round, "timeout during relaxation");
381                break;
382            }
383            if active_nodes.is_empty() {
384                debug!(round, "no active nodes, stopping early");
385                break;
386            }
387
388            let mut next_active: FxHashSet<NodeIndex> = FxHashSet::default();
389
390            for &u in &active_nodes {
391                let u_idx = u.index();
392                if amount[u_idx].is_zero() {
393                    continue;
394                }
395
396                let Some(token_u) = ctx.token_map.get(&u) else { continue };
397                let Some(edges) = ctx.adj.get(&u) else { continue };
398
399                for (v, component_id) in edges {
400                    let v_idx = v.index();
401
402                    // Single predecessor walk: skip if target token or component already in path
403                    if Self::path_has_conflict(u, *v, component_id, &predecessor) {
404                        continue;
405                    }
406
407                    // Skip disallowed connector tokens. Endpoints (token_in / token_out) are
408                    // always permitted regardless of the allowlist.
409                    if !self.connector_allows(ctx, order, *v) {
410                        continue;
411                    }
412
413                    let Some(token_v) = ctx.token_map.get(v) else { continue };
414
415                    // Overrides market data if passed in options
416                    let sim: &dyn tycho_simulation::tycho_common::simulation::protocol_sim::ProtocolSim =
417                        if let Some(s) = overrides.get(component_id) {
418                            s
419                        } else if let Some(s) = ctx.market_data.get_simulation_state(component_id) {
420                            s
421                        } else {
422                            continue;
423                        };
424
425                    let result =
426                        match sim.get_amount_out_guarded(amount[u_idx].clone(), token_u, token_v) {
427                            Ok(r) => r,
428                            Err(e) => {
429                                trace!(
430                                    component_id,
431                                    error = %e,
432                                    "simulation failed, skipping edge"
433                                );
434                                continue;
435                            }
436                        };
437
438                    let candidate_cumul_gas = &cumul_gas[u_idx] + &result.gas;
439
440                    // Compute spot price product for the candidate path (used for
441                    // gas-aware comparison and for final net amount calculation).
442                    let candidate_spot = Self::compute_edge_spot_product(
443                        spot_product[u_idx],
444                        component_id,
445                        ctx.node_address.get(&u),
446                        ctx.node_address.get(v),
447                        ctx.spot_prices.as_ref(),
448                    );
449
450                    // Gas-aware comparison: compare net amounts (gross - gas cost in token terms)
451                    let is_better = if gas_aware {
452                        let v_price = Self::resolve_token_price(
453                            ctx.node_address.get(v),
454                            ctx.token_prices.as_ref(),
455                            candidate_spot,
456                            ctx.node_address.get(&ctx.token_in_node),
457                        );
458                        let net_candidate = Self::gas_adjusted_amount(
459                            &result.amount,
460                            &candidate_cumul_gas,
461                            ctx.gas_price_wei.as_ref().unwrap(),
462                            v_price.as_ref(),
463                        );
464                        // Dust signal: this hop's input can't cover its own gas. Input-side, so
465                        // healthy and too-large orders (large inputs) never trip it. Gated on
466                        // net_candidate <= 0 (implied by input < hop gas) to keep the extra
467                        // price lookup off the hot path.
468                        if !input_below_hop_gas && net_candidate <= BigInt::ZERO {
469                            let u_price = Self::resolve_token_price(
470                                ctx.node_address.get(&u),
471                                ctx.token_prices.as_ref(),
472                                spot_product[u_idx],
473                                ctx.node_address.get(&ctx.token_in_node),
474                            );
475                            if Self::gas_adjusted_amount(
476                                &amount[u_idx],
477                                &result.gas,
478                                ctx.gas_price_wei.as_ref().unwrap(),
479                                u_price.as_ref(),
480                            ) <= BigInt::ZERO
481                            {
482                                input_below_hop_gas = true;
483                            }
484                        }
485                        let net_existing = Self::gas_adjusted_amount(
486                            &amount[v_idx],
487                            &cumul_gas[v_idx],
488                            ctx.gas_price_wei.as_ref().unwrap(),
489                            v_price.as_ref(),
490                        );
491                        net_candidate > net_existing
492                    } else {
493                        if result.amount.is_zero() {
494                            input_below_hop_gas = true;
495                        }
496                        result.amount > amount[v_idx]
497                    };
498
499                    if is_better {
500                        spot_product[v_idx] = candidate_spot;
501                        amount[v_idx] = result.amount;
502                        predecessor[v_idx] = Some((u, component_id.clone()));
503                        edge_gas[v_idx] = result.gas;
504                        cumul_gas[v_idx] = candidate_cumul_gas;
505                        next_active.insert(*v);
506                    }
507                }
508            }
509
510            active_nodes = next_active.into_iter().collect();
511            // Deterministic order: HashSet iteration is random per process.
512            // This pins SPFA to a fixed propagation order. The chosen order
513            // may not yield the optimal route (see module docs), but the
514            // previous random order was statistically no better.
515            active_nodes.sort_unstable();
516        }
517
518        SPFAResult { amount, predecessor, edge_gas, spot_product, input_below_hop_gas }
519    }
520
521    /// Whether the connector-token allowlist permits routing *into* node `v`.
522    /// Endpoints (token_in / token_out) are always permitted. No allowlist => all allowed.
523    fn connector_allows(&self, ctx: &BellmanFordContext, order: &Order, v: NodeIndex) -> bool {
524        let (Some(tokens), Some(v_addr)) = (&self.connector_tokens, ctx.node_address.get(&v))
525        else {
526            return true;
527        };
528        v_addr == order.token_in() || v_addr == order.token_out() || tokens.contains(v_addr)
529    }
530
531    /// Constructs a [`Route`] from a reconstructed path and SPFA output arrays.
532    fn build_route(
533        ctx: &BellmanFordContext,
534        path_edges: &[(NodeIndex, NodeIndex, ComponentId)],
535        amount: &[BigUint],
536        edge_gas: &[BigUint],
537        overrides: &MarketOverrides,
538    ) -> Result<Route, AlgorithmError> {
539        let mut swaps = Vec::with_capacity(path_edges.len());
540        let mut tokens: FxHashMap<Address, Token> = FxHashMap::default();
541
542        for (from_node, to_node, component_id) in path_edges {
543            let token_in = ctx
544                .token_map
545                .get(from_node)
546                .ok_or_else(|| AlgorithmError::DataNotFound {
547                    kind: "token",
548                    id: Some(format!("{:?}", from_node)),
549                })?;
550            let token_out = ctx
551                .token_map
552                .get(to_node)
553                .ok_or_else(|| AlgorithmError::DataNotFound {
554                    kind: "token",
555                    id: Some(format!("{:?}", to_node)),
556                })?;
557            let component = ctx
558                .market_data
559                .get_component(component_id)
560                .ok_or_else(|| AlgorithmError::DataNotFound {
561                    kind: "component",
562                    id: Some(component_id.clone()),
563                })?;
564            // Use the override's sim state if available so the route reflects overridden
565            // components.
566            let sim_state = overrides
567                .get(component_id)
568                .or_else(|| {
569                    ctx.market_data
570                        .get_simulation_state(component_id)
571                })
572                .ok_or_else(|| AlgorithmError::DataNotFound {
573                    kind: "simulation state",
574                    id: Some(component_id.clone()),
575                })?;
576
577            swaps.push(Swap::new(
578                component_id.clone(),
579                component.protocol_system.clone(),
580                token_in.address.clone(),
581                token_out.address.clone(),
582                amount[from_node.index()].clone(),
583                amount[to_node.index()].clone(),
584                edge_gas[to_node.index()].clone(),
585                component.clone(),
586                sim_state.clone_box(),
587            ));
588            tokens
589                .entry(token_in.address.clone())
590                .or_insert_with(|| Token::clone(token_in));
591            tokens
592                .entry(token_out.address.clone())
593                .or_insert_with(|| Token::clone(token_out));
594        }
595
596        Ok(Route::new(swaps, tokens)?)
597    }
598
599    /// Computes gas-adjusted net amount: gross_amount - gas_cost_in_token.
600    ///
601    /// If `token_price` is None (no conversion rate available), returns the gross amount
602    /// unchanged (falls back to gross comparison for this node).
603    fn gas_adjusted_amount(
604        gross: &BigUint,
605        cumul_gas: &BigUint,
606        gas_price_wei: &BigUint,
607        token_price: Option<&Price>,
608    ) -> BigInt {
609        match token_price {
610            Some(price) if !price.denominator.is_zero() => {
611                let gas_cost = cumul_gas * gas_price_wei * &price.numerator / &price.denominator;
612                BigInt::from(gross.clone()) - BigInt::from(gas_cost)
613            }
614            _ => BigInt::from(gross.clone()),
615        }
616    }
617
618    /// Computes the cumulative spot price product when extending a path by one edge.
619    ///
620    /// Returns `parent_spot * spot_price(component, token_u, token_v)`.
621    /// Returns 0.0 if the spot price is unavailable (disables the fallback for this path).
622    fn compute_edge_spot_product(
623        parent_spot: f64,
624        component_id: &ComponentId,
625        u_addr: Option<&Address>,
626        v_addr: Option<&Address>,
627        spot_prices: Option<&SpotPrices>,
628    ) -> f64 {
629        if parent_spot == 0.0 {
630            return 0.0;
631        }
632        let (Some(u), Some(v), Some(prices)) = (u_addr, v_addr, spot_prices) else {
633            return 0.0;
634        };
635        let key = (component_id.clone(), u.clone(), v.clone());
636        match prices.get(&key) {
637            Some(&spot) if spot > 0.0 => parent_spot * spot,
638            _ => 0.0,
639        }
640    }
641
642    /// Resolves the gas-to-token conversion rate for gas cost calculation.
643    ///
644    /// 1. Primary: use `token_prices[v_addr]` from derived data (direct lookup).
645    /// 2. Fallback: if `token_prices[token_in]` exists and `spot_product > 0`, estimate the rate as
646    ///    `token_prices[token_in] * spot_product` (converted to a Price).
647    /// 3. Last resort: returns None (gas adjustment skipped for this comparison).
648    fn resolve_token_price(
649        v_addr: Option<&Address>,
650        token_prices: Option<&TokenGasPrices>,
651        spot_product: f64,
652        token_in_addr: Option<&Address>,
653    ) -> Option<Price> {
654        let prices = token_prices?;
655        let addr = v_addr?;
656
657        // Primary: direct lookup
658        if let Some(price) = prices.get(addr) {
659            return Some(price.clone());
660        }
661
662        // Fallback: token_in price * cumulative spot product
663        if spot_product > 0.0 {
664            if let Some(in_price) = token_in_addr.and_then(|a| prices.get(a)) {
665                let in_rate_f64 = in_price.numerator.to_f64()? / in_price.denominator.to_f64()?;
666                let estimated_rate = in_rate_f64 * spot_product;
667                let denom = BigUint::from(10u64).pow(18);
668                let numer_f64 = estimated_rate * 1e18;
669                if numer_f64.is_finite() && numer_f64 > 0.0 {
670                    return Some(Price {
671                        numerator: BigUint::from(numer_f64 as u128),
672                        denominator: denom,
673                    });
674                }
675            }
676        }
677
678        None
679    }
680
681    /// Checks whether the target node or component conflicts with the existing path to `from`.
682    /// Walks the predecessor chain once, checking both conditions simultaneously.
683    pub(crate) fn path_has_conflict(
684        from: NodeIndex,
685        target_node: NodeIndex,
686        target_component: &ComponentId,
687        predecessor: &[Option<(NodeIndex, ComponentId)>],
688    ) -> bool {
689        let mut current = from;
690        loop {
691            if current == target_node {
692                return true;
693            }
694            match &predecessor[current.index()] {
695                Some((prev, cid)) => {
696                    if cid == target_component {
697                        return true;
698                    }
699                    current = *prev;
700                }
701                None => return false,
702            }
703        }
704    }
705
706    /// Reconstructs the path from token_out back to token_in by walking the predecessor
707    /// array.
708    pub(crate) fn reconstruct_path(
709        token_out: NodeIndex,
710        token_in: NodeIndex,
711        predecessor: &[Option<(NodeIndex, ComponentId)>],
712    ) -> Result<Vec<(NodeIndex, NodeIndex, ComponentId)>, AlgorithmError> {
713        let mut path = Vec::new();
714        let mut current = token_out;
715        let mut visited = FxHashSet::default();
716
717        while current != token_in {
718            if !visited.insert(current) {
719                return Err(AlgorithmError::Other("cycle in predecessor chain".to_string()));
720            }
721
722            let idx = current.index();
723            match &predecessor
724                .get(idx)
725                .and_then(|p| p.as_ref())
726            {
727                Some((prev_node, component_id)) => {
728                    path.push((*prev_node, current, component_id.clone()));
729                    current = *prev_node;
730                }
731                None => {
732                    return Err(AlgorithmError::Other(format!(
733                        "broken predecessor chain at node {idx}"
734                    )));
735                }
736            }
737        }
738
739        path.reverse();
740        Ok(path)
741    }
742
743    /// Extracts the part of the graph that can carry a route from `token_in_node` to
744    /// `token_out_node` in at most `max_hops`.
745    ///
746    /// Returns `(adjacency_list, token_nodes, component_ids)`, or `None` when no edge qualifies.
747    /// The caller says what an empty subgraph means for it.
748    ///
749    /// Both ends bound the walk. A token reached in `d` hops is only worth keeping if the
750    /// destination is still `max_hops - d` hops away or nearer, and the same holds edge by edge.
751    /// The distances used are the shortest ones, so nothing that could appear on a route of legal
752    /// length is discarded.
753    ///
754    /// Expanding from the source alone reaches most of the market. Every component it reaches gets
755    /// copied by the caller's `extract_subset`, held for the solve, and simulated during
756    /// relaxation, so bounding the walk bounds all three.
757    pub(crate) fn get_subgraph<'a>(
758        graph: &'a StableDiGraph<()>,
759        token_in: NodeIndex,
760        token_out: NodeIndex,
761        max_hops: usize,
762    ) -> Option<Subgraph<'a>> {
763        // Walked from the destination along outgoing edges, not incoming ones. Every pool in this
764        // graph is entered as a pair of opposite edges, so the two walks cover the same tokens and
765        // the outgoing one needs no reversed index.
766        let hops_to_token_out = Self::get_hops_to_reach(graph, token_out, max_hops);
767
768        let mut adj: FxHashMap<NodeIndex, Vec<(NodeIndex, ComponentId)>> = FxHashMap::default();
769        let mut token_nodes: FxHashSet<NodeIndex> = FxHashSet::default();
770        let mut component_ids: FxHashSet<&ComponentId> = FxHashSet::default();
771        let mut visited_nodes = FxHashSet::default();
772        let mut queued_nodes = VecDeque::new();
773
774        visited_nodes.insert(token_in);
775        token_nodes.insert(token_in);
776        queued_nodes.push_back((token_in, 0usize));
777
778        while let Some((node, depth_walked)) = queued_nodes.pop_front() {
779            if depth_walked >= max_hops {
780                continue;
781            }
782            for edge in graph.edges(node) {
783                let next_token = edge.target();
784
785                // Taking this edge spends one hop; the rest have to be enough to finish the route.
786                let Some(&hops_left) = hops_to_token_out.get(&next_token) else {
787                    // token_out is not reachable from next_token within the hop budget.
788                    continue;
789                };
790
791                if depth_walked + 1 + hops_left > max_hops {
792                    // Finishing the route from next_token would take more hops than are left.
793                    continue;
794                }
795
796                let component_id = &edge.weight().component_id;
797                adj.entry(node)
798                    .or_default()
799                    .push((next_token, component_id.clone()));
800                component_ids.insert(component_id);
801                token_nodes.insert(next_token);
802
803                if visited_nodes.insert(next_token) {
804                    queued_nodes.push_back((next_token, depth_walked + 1));
805                }
806            }
807        }
808
809        if adj.is_empty() {
810            return None;
811        }
812
813        Some((adj, token_nodes, component_ids))
814    }
815
816    /// Every node within `max_hops` of `from`, and how many hops each one takes to reach.
817    fn get_hops_to_reach(
818        graph: &StableDiGraph<()>,
819        from: NodeIndex,
820        max_hops: usize,
821    ) -> FxHashMap<NodeIndex, usize> {
822        let mut hops_to_reach: FxHashMap<NodeIndex, usize> = FxHashMap::default();
823        hops_to_reach.insert(from, 0);
824
825        let mut frontier = vec![from];
826        for depth in 1..=max_hops {
827            let mut next = Vec::new();
828            for node in frontier {
829                for neighbor in graph.neighbors(node) {
830                    if hops_to_reach.contains_key(&neighbor) {
831                        continue;
832                    }
833                    hops_to_reach.insert(neighbor, depth);
834                    next.push(neighbor);
835                }
836            }
837            frontier = next;
838        }
839
840        hops_to_reach
841    }
842
843    /// Computes net_amount_out by subtracting gas costs from the output amount.
844    ///
845    /// Uses the same resolution strategy as relaxation: direct token price lookup
846    /// first, then cumulative spot price product fallback for tokens not in the price
847    /// table.
848    #[allow(clippy::too_many_arguments)]
849    fn compute_net_amount_out(
850        amount_out: &BigUint,
851        route: &Route,
852        gas_price: &BigUint,
853        token_prices: Option<&TokenGasPrices>,
854        spot_product: &[f64],
855        node_address: &FxHashMap<NodeIndex, Address>,
856        token_in_node: NodeIndex,
857    ) -> Result<BigInt, AlgorithmError> {
858        let last_swap = route.swaps().last().ok_or_else(|| {
859            AlgorithmError::Other("compute_net_amount_out called with empty route".to_string())
860        })?;
861
862        let total_gas = route.total_gas();
863
864        if gas_price.is_zero() {
865            warn!("missing gas price, returning gross amount_out");
866            return Ok(BigInt::from(amount_out.clone()));
867        }
868
869        let gas_cost_wei = &total_gas * gas_price;
870
871        // Find the output token's node to get its spot_product for the fallback
872        let out_addr = last_swap.token_out();
873        let out_node_spot = node_address
874            .iter()
875            .find(|(_, addr)| *addr == out_addr)
876            .and_then(|(node, _)| spot_product.get(node.index()).copied())
877            .unwrap_or(0.0);
878
879        let output_price = Self::resolve_token_price(
880            Some(out_addr),
881            token_prices,
882            out_node_spot,
883            node_address.get(&token_in_node),
884        );
885
886        Ok(match output_price {
887            Some(price) if !price.denominator.is_zero() => {
888                let gas_cost = &gas_cost_wei * &price.numerator / &price.denominator;
889                BigInt::from(amount_out.clone()) - BigInt::from(gas_cost)
890            }
891            _ => {
892                warn!("no gas price for output token, returning gross amount_out");
893                BigInt::from(amount_out.clone())
894            }
895        })
896    }
897}
898
899impl Algorithm for BellmanFordAlgorithm {
900    type GraphType = StableDiGraph<()>;
901    type GraphManager = PetgraphStableDiGraphManager<()>;
902
903    fn name(&self) -> &str {
904        "bellman_ford"
905    }
906
907    #[instrument(level = "debug", skip_all, fields(order_id = %order.id()))]
908    async fn find_best_route(
909        &self,
910        graph: &Self::GraphType,
911        market: MarketData,
912        label: Option<StateLabel>,
913        derived: Option<SharedDerivedDataRef>,
914        order: &Order,
915    ) -> Result<RouteResult, AlgorithmError> {
916        let ctx = self
917            .build_context(graph, market, label, derived, order)
918            .await?;
919        self.find_single_route(&ctx, order, FindRouteOptions::default())
920    }
921
922    fn computation_requirements(&self) -> ComputationRequirements {
923        // Static requirements for independent computations; cannot conflict.
924        // The trait returns ComputationRequirements (not Result), so expect is
925        // the appropriate pattern for this infallible case.
926        ComputationRequirements::none()
927            .allow_stale("token_prices")
928            .expect("token_prices requirement conflicts (bug)")
929            .allow_stale("spot_prices")
930            .expect("spot_prices requirement conflicts (bug)")
931    }
932
933    fn timeout(&self) -> Duration {
934        self.timeout
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use std::sync::Arc;
941
942    use num_bigint::BigInt;
943    use tokio::sync::RwLock;
944    use tycho_simulation::{
945        tycho_common::{models::Address, simulation::protocol_sim::ProtocolSim},
946        tycho_ethereum::gas::{BlockGasPrice, GasPrice},
947    };
948
949    use super::*;
950    use crate::{
951        algorithm::test_utils::{component, order, token, MockProtocolSim},
952        derived::{types::TokenGasPrices, DerivedData},
953        feed::market_data::{MarketData, MarketState},
954        graph::GraphManager,
955        types::quote::OrderSide,
956    };
957
958    // ==================== Test Utilities ====================
959
960    /// Sets up market and graph with `()` edge weights for BellmanFord tests.
961    fn setup_market_bf(
962        components: Vec<(&str, &Token, &Token, MockProtocolSim)>,
963    ) -> (MarketData, PetgraphStableDiGraphManager<()>) {
964        let mut market = MarketState::new();
965
966        market.update_gas_price(BlockGasPrice {
967            block_number: 1,
968            block_hash: Default::default(),
969            block_timestamp: 0,
970            pricing: GasPrice::Legacy { gas_price: BigUint::from(100u64) },
971        });
972        market.update_last_updated(crate::types::BlockInfo::new(1, "0x00".into(), 0));
973
974        for (component_id, token_in, token_out, state) in components {
975            let tokens = vec![token_in.clone(), token_out.clone()];
976            let comp = component(component_id, &tokens);
977            market.upsert_components(std::iter::once(comp));
978            market.update_states([(
979                component_id.to_string(),
980                Box::new(state) as Box<dyn ProtocolSim>,
981            )]);
982            market.upsert_tokens(tokens);
983        }
984
985        let mut graph_manager = PetgraphStableDiGraphManager::default();
986        graph_manager.initialize_graph(&market.component_topology());
987
988        (MarketData::new(Arc::new(RwLock::new(market))), graph_manager)
989    }
990
991    fn setup_derived_with_token_prices(
992        token_addresses: &[Address],
993    ) -> crate::derived::SharedDerivedDataRef {
994        use tycho_simulation::tycho_core::simulation::protocol_sim::Price;
995
996        let mut token_prices: TokenGasPrices = FxHashMap::default();
997        for address in token_addresses {
998            token_prices.insert(
999                address.clone(),
1000                Price { numerator: BigUint::from(1u64), denominator: BigUint::from(1u64) },
1001            );
1002        }
1003
1004        let mut derived_data = DerivedData::new();
1005        derived_data.set_token_prices(token_prices, vec![], 1, true);
1006        Arc::new(RwLock::new(derived_data))
1007    }
1008
1009    fn bf_algorithm(max_hops: usize, timeout_ms: u64) -> BellmanFordAlgorithm {
1010        BellmanFordAlgorithm::with_config(
1011            AlgorithmConfig::new(1, max_hops, Duration::from_millis(timeout_ms), None).unwrap(),
1012        )
1013    }
1014
1015    // ==================== Unit Tests ====================
1016
1017    /// The subgraph must hold everything a route could use and nothing else.
1018    ///
1019    /// Dropping too little is only slow, so no test would catch it; dropping too much loses routes
1020    /// silently, and the route that dies first is the one using every hop it is allowed.
1021    ///
1022    /// ```text
1023    ///   A --[ab]-- B --[bc]-- C      the only route, and it needs both hops
1024    ///   A --[ad]-- D                 a dead end: D reaches nothing else
1025    /// ```
1026    #[test]
1027    fn test_get_subgraph_keeps_full_length_routes_and_drops_dead_ends() {
1028        let token_a = token(0x01, "A");
1029        let token_b = token(0x02, "B");
1030        let token_c = token(0x03, "C");
1031        let token_d = token(0x04, "D");
1032
1033        let (_, manager) = setup_market_bf(vec![
1034            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1035            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1036            ("component_ad", &token_a, &token_d, MockProtocolSim::new(5.0)),
1037        ]);
1038        let graph = manager.graph();
1039        let node = |address: &Address| {
1040            graph
1041                .node_indices()
1042                .find(|&n| &graph[n] == address)
1043                .expect("token in graph")
1044        };
1045        let (adj, _, component_ids) = BellmanFordAlgorithm::get_subgraph(
1046            graph,
1047            node(&token_a.address),
1048            node(&token_c.address),
1049            2,
1050        )
1051        .unwrap();
1052
1053        let kept = |id: &str| {
1054            component_ids
1055                .iter()
1056                .any(|component_id| *component_id == id)
1057        };
1058
1059        // A -> B -> C spends the whole budget, so an off-by-one in the test would drop it.
1060        assert!(kept("component_ab"), "the route's first hop must survive");
1061        assert!(kept("component_bc"), "the route's second hop must survive");
1062        // D is reachable from A but reaches nothing, so no route can pass through it. Keeping it
1063        // would deep-copy its pool state and simulate it during relaxation, both for nothing.
1064        assert!(!kept("component_ad"), "a dead end must not be kept");
1065
1066        // Nor should stepping back towards the source be kept: from B, returning to A leaves no
1067        // budget to reach C.
1068        let from_b = adj
1069            .get(&node(&token_b.address))
1070            .map(Vec::as_slice)
1071            .unwrap_or_default();
1072        assert!(
1073            from_b
1074                .iter()
1075                .all(|(target, _)| *target != node(&token_a.address)),
1076            "B -> A cannot finish the route and must not be kept"
1077        );
1078    }
1079
1080    /// A token can sit inside the hop budget of both ends and still be on no legal route.
1081    ///
1082    /// D is one hop from the source and two from the destination, and the budget is two, so both
1083    /// halves fit on their own while the route through D needs three. Only the sum rules it out,
1084    /// which is the one case the arithmetic decides: a dead end is already gone by then, dropped
1085    /// for having no distance to the destination at all.
1086    ///
1087    /// ```text
1088    ///   A --[ab]-- B --[bc]-- C        two hops, the whole budget
1089    ///   A --[ad]-- D --[de]-- E --[ec]-- C     three hops through D
1090    /// ```
1091    #[test]
1092    fn test_get_subgraph_drops_detours_that_cannot_finish_in_budget() {
1093        let token_a = token(0x01, "A");
1094        let token_b = token(0x02, "B");
1095        let token_c = token(0x03, "C");
1096        let token_d = token(0x04, "D");
1097        let token_e = token(0x05, "E");
1098
1099        let (_, manager) = setup_market_bf(vec![
1100            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1101            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1102            ("component_ad", &token_a, &token_d, MockProtocolSim::new(5.0)),
1103            ("component_de", &token_d, &token_e, MockProtocolSim::new(5.0)),
1104            ("component_ec", &token_e, &token_c, MockProtocolSim::new(5.0)),
1105        ]);
1106        let graph = manager.graph();
1107        let node = |address: &Address| {
1108            graph
1109                .node_indices()
1110                .find(|&n| &graph[n] == address)
1111                .expect("token in graph")
1112        };
1113        let (_, token_nodes, component_ids) = BellmanFordAlgorithm::get_subgraph(
1114            graph,
1115            node(&token_a.address),
1116            node(&token_c.address),
1117            2,
1118        )
1119        .unwrap();
1120
1121        let kept = |id: &str| {
1122            component_ids
1123                .iter()
1124                .any(|component_id| *component_id == id)
1125        };
1126
1127        assert!(kept("component_ab"), "the two-hop route's first leg must survive");
1128        assert!(kept("component_bc"), "the two-hop route's second leg must survive");
1129
1130        // One hop spent reaching D, two more needed to leave it: three in a budget of two.
1131        assert!(!kept("component_ad"), "the step into a detour must not be kept");
1132        assert!(!kept("component_de"), "nor anything further along it");
1133        assert!(!kept("component_ec"), "nor its last leg into the destination");
1134        assert!(
1135            !token_nodes.contains(&node(&token_d.address)),
1136            "a token no legal route reaches must not be kept"
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn test_linear_path_found() {
1142        let token_a = token(0x01, "A");
1143        let token_b = token(0x02, "B");
1144        let token_c = token(0x03, "C");
1145        let token_d = token(0x04, "D");
1146
1147        let (market, manager) = setup_market_bf(vec![
1148            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1149            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1150            ("component_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
1151        ]);
1152
1153        let algo = bf_algorithm(4, 1000);
1154        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1155
1156        let result = algo
1157            .find_best_route(manager.graph(), market, None, None, &ord)
1158            .await
1159            .unwrap();
1160
1161        assert_eq!(result.route().swaps().len(), 3);
1162        // A->B: 100*2=200, B->C: 200*3=600, C->D: 600*4=2400
1163        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1164        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1165        assert_eq!(result.route().swaps()[2].amount_out(), &BigUint::from(2400u64));
1166    }
1167
1168    #[tokio::test]
1169    async fn test_picks_better_of_two_paths() {
1170        // Diamond graph: A->B->D (2*3=6x) vs A->C->D (4*1=4x)
1171        let token_a = token(0x01, "A");
1172        let token_b = token(0x02, "B");
1173        let token_c = token(0x03, "C");
1174        let token_d = token(0x04, "D");
1175
1176        let (market, manager) = setup_market_bf(vec![
1177            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1178            ("component_bd", &token_b, &token_d, MockProtocolSim::new(3.0)),
1179            ("component_ac", &token_a, &token_c, MockProtocolSim::new(4.0)),
1180            ("component_cd", &token_c, &token_d, MockProtocolSim::new(1.0)),
1181        ]);
1182
1183        let algo = bf_algorithm(3, 1000);
1184        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1185
1186        let result = algo
1187            .find_best_route(manager.graph(), market, None, None, &ord)
1188            .await
1189            .unwrap();
1190
1191        // A->B->D: 100*2*3=600 is better than A->C->D: 100*4*1=400
1192        assert_eq!(result.route().swaps().len(), 2);
1193        assert_eq!(result.route().swaps()[0].component_id(), "component_ab");
1194        assert_eq!(result.route().swaps()[1].component_id(), "component_bd");
1195        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1196    }
1197
1198    #[tokio::test]
1199    async fn test_parallel_components() {
1200        // Two components between A and B with different multipliers
1201        let token_a = token(0x01, "A");
1202        let token_b = token(0x02, "B");
1203
1204        let (market, manager) = setup_market_bf(vec![
1205            ("component1", &token_a, &token_b, MockProtocolSim::new(2.0)),
1206            ("component2", &token_a, &token_b, MockProtocolSim::new(5.0)),
1207        ]);
1208
1209        let algo = bf_algorithm(2, 1000);
1210        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1211
1212        let result = algo
1213            .find_best_route(manager.graph(), market, None, None, &ord)
1214            .await
1215            .unwrap();
1216
1217        assert_eq!(result.route().swaps().len(), 1);
1218        assert_eq!(result.route().swaps()[0].component_id(), "component2");
1219        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(500u64));
1220    }
1221
1222    #[tokio::test]
1223    async fn test_no_path_returns_error() {
1224        let token_a = token(0x01, "A");
1225        let token_b = token(0x02, "B");
1226        let token_c = token(0x03, "C");
1227
1228        // A-B connected, C disconnected
1229        let (market, manager) =
1230            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1231
1232        // Add token_c to market without connecting it
1233        {
1234            let mut m = market.write().await;
1235            m.upsert_tokens(vec![token_c.clone()]);
1236        }
1237
1238        let algo = bf_algorithm(3, 1000);
1239        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1240
1241        let result = algo
1242            .find_best_route(manager.graph(), market, None, None, &ord)
1243            .await;
1244        assert!(matches!(result, Err(AlgorithmError::NoPath { .. })));
1245    }
1246
1247    #[tokio::test]
1248    async fn test_source_not_in_graph() {
1249        let token_a = token(0x01, "A");
1250        let token_b = token(0x02, "B");
1251        let token_x = token(0x99, "X");
1252
1253        let (market, manager) =
1254            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1255
1256        let algo = bf_algorithm(3, 1000);
1257        let ord = order(&token_x, &token_b, 100, OrderSide::Sell);
1258
1259        let result = algo
1260            .find_best_route(manager.graph(), market, None, None, &ord)
1261            .await;
1262        assert!(matches!(
1263            result,
1264            Err(AlgorithmError::NoPath { reason: NoPathReason::SourceTokenNotInGraph, .. })
1265        ));
1266    }
1267
1268    #[tokio::test]
1269    async fn test_amount_too_small_when_reachable_but_zero_output() {
1270        let token_a = token(0x01, "A");
1271        let token_b = token(0x02, "B");
1272        // Reachable component, but rate 0.5 on a 1-unit input floors to 0 output.
1273        let (market, manager) =
1274            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(0.5))]);
1275        let algo = bf_algorithm(3, 1000);
1276        let ord = order(&token_a, &token_b, 1, OrderSide::Sell);
1277
1278        let result = algo
1279            .find_best_route(manager.graph(), market, None, None, &ord)
1280            .await;
1281        assert!(matches!(
1282            result,
1283            Err(AlgorithmError::NoPath { reason: NoPathReason::AmountTooSmall, .. })
1284        ));
1285    }
1286
1287    #[tokio::test]
1288    async fn test_amount_too_small_when_dust_occurs_mid_route() {
1289        // A->B floors 1*0.5 to 0 one hop before token_out: dust mid-route must still
1290        // be AmountTooSmall.
1291        let token_a = token(0x01, "A");
1292        let token_b = token(0x02, "B");
1293        let token_c = token(0x03, "C");
1294        let (market, manager) = setup_market_bf(vec![
1295            ("component_ab", &token_a, &token_b, MockProtocolSim::new(0.5)),
1296            ("component_bc", &token_b, &token_c, MockProtocolSim::new(2.0)),
1297        ]);
1298        let algo = bf_algorithm(3, 1000);
1299        let ord = order(&token_a, &token_c, 1, OrderSide::Sell);
1300
1301        let result = algo
1302            .find_best_route(manager.graph(), market, None, None, &ord)
1303            .await;
1304        assert!(matches!(
1305            result,
1306            Err(AlgorithmError::NoPath { reason: NoPathReason::AmountTooSmall, .. })
1307        ));
1308    }
1309
1310    #[tokio::test]
1311    async fn test_no_graph_path_when_amount_too_large_for_liquidity() {
1312        // Output 2000 exceeds liquidity 500, so the sim errors: a too-large amount must
1313        // never be mislabeled AmountTooSmall.
1314        let token_a = token(0x01, "A");
1315        let token_b = token(0x02, "B");
1316        let (market, manager) = setup_market_bf(vec![(
1317            "component_ab",
1318            &token_a,
1319            &token_b,
1320            MockProtocolSim::new(2.0).with_liquidity(500),
1321        )]);
1322        let algo = bf_algorithm(2, 1000);
1323        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1324
1325        let result = algo
1326            .find_best_route(manager.graph(), market, None, None, &ord)
1327            .await;
1328        assert!(matches!(
1329            result,
1330            Err(AlgorithmError::NoPath { reason: NoPathReason::NoGraphPath, .. })
1331        ));
1332    }
1333
1334    #[tokio::test]
1335    async fn test_no_graph_path_when_unreachable_within_hops() {
1336        // A->B->C exists, but max_hops=1 leaves C out of the subgraph.
1337        let token_a = token(0x01, "A");
1338        let token_b = token(0x02, "B");
1339        let token_c = token(0x03, "C");
1340        let (market, manager) = setup_market_bf(vec![
1341            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1342            ("component_bc", &token_b, &token_c, MockProtocolSim::new(2.0)),
1343        ]);
1344        let algo = bf_algorithm(1, 1000);
1345        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1346
1347        let result = algo
1348            .find_best_route(manager.graph(), market, None, None, &ord)
1349            .await;
1350        assert!(matches!(
1351            result,
1352            Err(AlgorithmError::NoPath { reason: NoPathReason::NoGraphPath, .. })
1353        ));
1354    }
1355
1356    #[tokio::test]
1357    async fn test_no_graph_path_when_connector_tokens_exclude_intermediate() {
1358        // B is not in the connector allowlist, so SPFA never simulates into it:
1359        // policy exclusion reads as NoGraphPath, not AmountTooSmall.
1360        let token_a = token(0x01, "A");
1361        let token_b = token(0x02, "B");
1362        let token_c = token(0x03, "C");
1363
1364        let (market, manager) = setup_market_bf(vec![
1365            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1366            ("component_bc", &token_b, &token_c, MockProtocolSim::new(2.0)),
1367        ]);
1368
1369        let algo = BellmanFordAlgorithm::with_config(
1370            AlgorithmConfig::new(1, 3, Duration::from_millis(1000), None)
1371                .unwrap()
1372                .with_connector_tokens(FxHashSet::default()),
1373        );
1374        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1375
1376        let result = algo
1377            .find_best_route(manager.graph(), market, None, None, &ord)
1378            .await;
1379        assert!(matches!(
1380            result,
1381            Err(AlgorithmError::NoPath { reason: NoPathReason::NoGraphPath, .. })
1382        ));
1383    }
1384
1385    #[tokio::test]
1386    async fn test_destination_not_in_graph() {
1387        let token_a = token(0x01, "A");
1388        let token_b = token(0x02, "B");
1389        let token_x = token(0x99, "X");
1390
1391        let (market, manager) =
1392            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1393
1394        let algo = bf_algorithm(3, 1000);
1395        let ord = order(&token_a, &token_x, 100, OrderSide::Sell);
1396
1397        let result = algo
1398            .find_best_route(manager.graph(), market, None, None, &ord)
1399            .await;
1400        assert!(matches!(
1401            result,
1402            Err(AlgorithmError::NoPath { reason: NoPathReason::DestinationTokenNotInGraph, .. })
1403        ));
1404    }
1405
1406    #[tokio::test]
1407    async fn test_respects_max_hops() {
1408        // Path A->B->C->D exists but requires 3 hops; max_hops=2
1409        let token_a = token(0x01, "A");
1410        let token_b = token(0x02, "B");
1411        let token_c = token(0x03, "C");
1412        let token_d = token(0x04, "D");
1413
1414        let (market, manager) = setup_market_bf(vec![
1415            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1416            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1417            ("component_cd", &token_c, &token_d, MockProtocolSim::new(4.0)),
1418        ]);
1419
1420        let algo = bf_algorithm(2, 1000);
1421        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1422
1423        let result = algo
1424            .find_best_route(manager.graph(), market, None, None, &ord)
1425            .await;
1426        assert!(
1427            matches!(result, Err(AlgorithmError::NoPath { .. })),
1428            "Should not find 3-hop path with max_hops=2"
1429        );
1430    }
1431
1432    #[tokio::test]
1433    async fn test_source_token_revisit_blocked() {
1434        // Forbid-revisits prevents paths like A->B->A->B->C. The algorithm
1435        // should find the direct A->B->C path instead.
1436        let token_a = token(0x01, "A");
1437        let token_b = token(0x02, "B");
1438        let token_c = token(0x03, "C");
1439
1440        let (market, manager) = setup_market_bf(vec![
1441            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1442            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1443        ]);
1444
1445        let algo = bf_algorithm(4, 1000);
1446        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1447
1448        let result = algo
1449            .find_best_route(manager.graph(), market, None, None, &ord)
1450            .await
1451            .unwrap();
1452
1453        // Should find exactly the 2-hop path A->B->C = 100*2*3 = 600
1454        assert_eq!(result.route().swaps().len(), 2);
1455        assert_eq!(result.route().swaps()[0].component_id(), "component_ab");
1456        assert_eq!(result.route().swaps()[1].component_id(), "component_bc");
1457        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1458    }
1459
1460    #[tokio::test]
1461    async fn test_hub_token_revisit_blocked() {
1462        // Forbid-revisits blocks A->B->C->B->D (B visited twice).
1463        // The algorithm should find the direct A->B->D = 400 instead.
1464        let token_a = token(0x01, "A");
1465        let token_c = token(0x02, "C");
1466        let token_b = token(0x03, "B");
1467        let token_d = token(0x04, "D");
1468
1469        let (market, manager) = setup_market_bf(vec![
1470            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1471            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1472            ("component_cb", &token_c, &token_b, MockProtocolSim::new(100.0)),
1473            ("component_bd", &token_b, &token_d, MockProtocolSim::new(2.0)),
1474        ]);
1475
1476        let algo = bf_algorithm(4, 1000);
1477        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1478
1479        let result = algo
1480            .find_best_route(manager.graph(), market, None, None, &ord)
1481            .await
1482            .unwrap();
1483
1484        // Should find A->B->D = 100*2*2 = 400 (the direct 2-hop path)
1485        // The 4-hop revisit path A->B->C->B->D is blocked
1486        assert_eq!(result.route().swaps().len(), 2, "should use direct 2-hop path");
1487        assert_eq!(result.route().swaps()[0].component_id(), "component_ab");
1488        assert_eq!(result.route().swaps()[1].component_id(), "component_bd");
1489        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(400u64));
1490    }
1491
1492    #[tokio::test]
1493    async fn test_route_amounts_are_sequential() {
1494        // Verify that swap amount_in[i+1] == amount_out[i] in the built route
1495        let token_a = token(0x01, "A");
1496        let token_b = token(0x02, "B");
1497        let token_c = token(0x03, "C");
1498
1499        let (market, manager) = setup_market_bf(vec![
1500            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1501            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1502        ]);
1503
1504        let algo = bf_algorithm(3, 1000);
1505        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1506
1507        let result = algo
1508            .find_best_route(manager.graph(), market, None, None, &ord)
1509            .await
1510            .unwrap();
1511
1512        assert_eq!(result.route().swaps().len(), 2);
1513        // amount_in of second swap == amount_out of first swap
1514        assert_eq!(result.route().swaps()[1].amount_in(), result.route().swaps()[0].amount_out());
1515    }
1516
1517    #[tokio::test]
1518    async fn test_gas_deduction() {
1519        let token_a = token(0x01, "A");
1520        let token_b = token(0x02, "B");
1521
1522        let (market, manager) = setup_market_bf(vec![(
1523            "component1",
1524            &token_a,
1525            &token_b,
1526            MockProtocolSim::new(2.0).with_gas(10),
1527        )]);
1528
1529        let algo = bf_algorithm(2, 1000);
1530        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1531
1532        let derived = setup_derived_with_token_prices(std::slice::from_ref(&token_b.address));
1533
1534        let result = algo
1535            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1536            .await
1537            .unwrap();
1538
1539        // Output: 1000 * 2 = 2000
1540        // Gas: 10 gas units * 100 gas_price = 1000 wei * 1/1 price = 1000
1541        // Net: 2000 - 1000 = 1000
1542        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
1543        assert_eq!(result.net_amount_out(), &BigInt::from(1000));
1544    }
1545
1546    #[tokio::test]
1547    async fn test_timeout_respected() {
1548        let token_a = token(0x01, "A");
1549        let token_b = token(0x02, "B");
1550        let token_c = token(0x03, "C");
1551
1552        let (market, manager) = setup_market_bf(vec![
1553            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1554            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1555        ]);
1556
1557        // 0ms timeout
1558        let algo = bf_algorithm(3, 0);
1559        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1560
1561        let result = algo
1562            .find_best_route(manager.graph(), market, None, None, &ord)
1563            .await;
1564
1565        // With 0ms timeout, we expect either:
1566        // - A partial result (if some layers completed before timeout check)
1567        // - Timeout error
1568        // - NoPath (if timeout prevented completing enough layers to reach dest)
1569        match result {
1570            Ok(r) => {
1571                assert!(!r.route().swaps().is_empty());
1572            }
1573            Err(AlgorithmError::Timeout { .. }) | Err(AlgorithmError::NoPath { .. }) => {
1574                // Both are acceptable for 0ms timeout
1575            }
1576            Err(e) => panic!("Unexpected error: {:?}", e),
1577        }
1578    }
1579
1580    // ==================== Integration-style Tests ====================
1581
1582    #[tokio::test]
1583    async fn test_with_fees() {
1584        let token_a = token(0x01, "A");
1585        let token_b = token(0x02, "B");
1586
1587        // Component with 10% fee
1588        let (market, manager) = setup_market_bf(vec![(
1589            "component1",
1590            &token_a,
1591            &token_b,
1592            MockProtocolSim::new(2.0).with_fee(0.1),
1593        )]);
1594
1595        let algo = bf_algorithm(2, 1000);
1596        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1597
1598        let result = algo
1599            .find_best_route(manager.graph(), market, None, None, &ord)
1600            .await
1601            .unwrap();
1602
1603        // 1000 * 2 * (1-0.1) = 1800
1604        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(1800u64));
1605    }
1606
1607    #[tokio::test]
1608    async fn test_large_trade_slippage() {
1609        let token_a = token(0x01, "A");
1610        let token_b = token(0x02, "B");
1611
1612        // Component with limited liquidity (500 tokens)
1613        let (market, manager) = setup_market_bf(vec![(
1614            "component1",
1615            &token_a,
1616            &token_b,
1617            MockProtocolSim::new(2.0).with_liquidity(500),
1618        )]);
1619
1620        let algo = bf_algorithm(2, 1000);
1621        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
1622
1623        // Should fail due to insufficient liquidity
1624        let result = algo
1625            .find_best_route(manager.graph(), market, None, None, &ord)
1626            .await;
1627        assert!(
1628            matches!(result, Err(AlgorithmError::NoPath { .. })),
1629            "Should fail when trade exceeds component liquidity"
1630        );
1631    }
1632
1633    #[tokio::test]
1634    async fn test_disconnected_tokens_return_no_path() {
1635        // A-B connected, D-E disconnected. Routing A->E should fail.
1636        let token_a = token(0x01, "A");
1637        let token_b = token(0x02, "B");
1638        let token_d = token(0x04, "D");
1639        let token_e = token(0x05, "E");
1640
1641        let (market, manager) = setup_market_bf(vec![
1642            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1643            ("component_de", &token_d, &token_e, MockProtocolSim::new(4.0)),
1644        ]);
1645
1646        let algo = bf_algorithm(3, 1000);
1647        let ord = order(&token_a, &token_e, 100, OrderSide::Sell);
1648
1649        let result = algo
1650            .find_best_route(manager.graph(), market, None, None, &ord)
1651            .await;
1652        assert!(
1653            matches!(result, Err(AlgorithmError::NoPath { .. })),
1654            "should not find path to disconnected component"
1655        );
1656    }
1657
1658    #[tokio::test]
1659    async fn test_spfa_skips_failed_simulations() {
1660        // Component that will fail simulation (liquidity=0 would cause error for any amount)
1661        let token_a = token(0x01, "A");
1662        let token_b = token(0x02, "B");
1663        let token_c = token(0x03, "C");
1664
1665        let (market, manager) = setup_market_bf(vec![
1666            // Direct path with failing component
1667            ("component_ab_bad", &token_a, &token_b, MockProtocolSim::new(2.0).with_liquidity(0)),
1668            // Alternative path that works
1669            ("component_ac", &token_a, &token_c, MockProtocolSim::new(2.0)),
1670            ("component_cb", &token_c, &token_b, MockProtocolSim::new(3.0)),
1671        ]);
1672
1673        let algo = bf_algorithm(3, 1000);
1674        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1675
1676        let result = algo
1677            .find_best_route(manager.graph(), market, None, None, &ord)
1678            .await;
1679
1680        // Should find A->C->B despite A->B failing
1681        // Note: MockProtocolSim with liquidity=0 will fail for amount > 0
1682        // The direct A->B edge should be skipped and the 2-hop path used
1683        match result {
1684            Ok(r) => {
1685                // Found alternative path
1686                assert!(!r.route().swaps().is_empty());
1687            }
1688            Err(AlgorithmError::NoPath { .. }) => {
1689                // Also acceptable if liquidity=0 blocks all paths through B
1690                // (since the failing component might also block the reverse B->A edge)
1691            }
1692            Err(e) => panic!("Unexpected error: {:?}", e),
1693        }
1694    }
1695
1696    #[tokio::test]
1697    async fn test_resimulation_produces_correct_amounts() {
1698        // Verifies that re-simulation produces the same correct sequential amounts
1699        let token_a = token(0x01, "A");
1700        let token_b = token(0x02, "B");
1701        let token_c = token(0x03, "C");
1702
1703        let (market, manager) = setup_market_bf(vec![
1704            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1705            ("component_bc", &token_b, &token_c, MockProtocolSim::new(3.0)),
1706        ]);
1707
1708        let algo = bf_algorithm(3, 1000);
1709        let ord = order(&token_a, &token_c, 100, OrderSide::Sell);
1710
1711        let result = algo
1712            .find_best_route(manager.graph(), market, None, None, &ord)
1713            .await
1714            .unwrap();
1715
1716        // Verify the final amounts are from re-simulation, not relaxation
1717        // A->B: 100*2=200, B->C: 200*3=600
1718        assert_eq!(result.route().swaps()[0].amount_in(), &BigUint::from(100u64));
1719        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1720        assert_eq!(result.route().swaps()[1].amount_in(), &BigUint::from(200u64));
1721        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1722    }
1723
1724    // ==================== Trait getter tests ====================
1725
1726    #[test]
1727    fn algorithm_name() {
1728        let algo = bf_algorithm(4, 200);
1729        assert_eq!(algo.name(), "bellman_ford");
1730    }
1731
1732    #[test]
1733    fn algorithm_timeout() {
1734        let algo = bf_algorithm(4, 200);
1735        assert_eq!(algo.timeout(), Duration::from_millis(200));
1736    }
1737
1738    // ==================== Forbid-revisit helper tests ====================
1739
1740    #[tokio::test]
1741    async fn test_gas_aware_relaxation_picks_cheaper_path() {
1742        // Diamond graph: A -> B -> D vs A -> C -> D
1743        // Path via B: higher gross output (3x * 2x = 6x) but extreme gas (100M per hop)
1744        // Path via C: lower gross output (2x * 2x = 4x) but cheap gas (100 per hop)
1745        //
1746        // With gas_price=100, token_prices[D]=1:1 for WETH conversion:
1747        // Path B gas cost: (100M + 100M) * 100 * 1 = 20B
1748        // Path C gas cost: (100 + 100) * 100 * 1 = 20K
1749        //
1750        // For an input of 1B:
1751        // Path B: gross = 6B, net = 6B - 20B = -14B
1752        // Path C: gross = 4B, net = 4B - 20K ≈ 4B
1753        //
1754        // Without gas awareness: Path B wins (6B > 4B)
1755        // With gas awareness: Path C wins (4B net > -14B net)
1756        let token_a = token(0x01, "A");
1757        let token_b = token(0x02, "B");
1758        let token_c = token(0x03, "C");
1759        let token_d = token(0x04, "D");
1760
1761        let high_gas: u64 = 100_000_000;
1762        let low_gas: u64 = 100;
1763
1764        let (market, manager) = setup_market_bf(vec![
1765            ("component_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1766            ("component_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1767            ("component_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1768            ("component_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1769        ]);
1770
1771        let algo = bf_algorithm(3, 1000);
1772        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1773
1774        // With gas-aware relaxation (derived data with token prices + gas price in market)
1775        let derived = setup_derived_with_token_prices(&[
1776            token_a.address.clone(),
1777            token_b.address.clone(),
1778            token_c.address.clone(),
1779            token_d.address.clone(),
1780        ]);
1781
1782        let result = algo
1783            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1784            .await
1785            .unwrap();
1786
1787        // Gas-aware relaxation should pick the cheaper path A -> C -> D
1788        assert_eq!(result.route().swaps().len(), 2);
1789        assert_eq!(result.route().swaps()[0].component_id(), "component_ac");
1790        assert_eq!(result.route().swaps()[1].component_id(), "component_cd");
1791    }
1792
1793    #[tokio::test]
1794    async fn test_gas_aware_falls_back_to_gross_without_derived() {
1795        // Same diamond graph as above, but without derived data.
1796        // Should fall back to gross comparison and pick Path B (higher gross).
1797        let token_a = token(0x01, "A");
1798        let token_b = token(0x02, "B");
1799        let token_c = token(0x03, "C");
1800        let token_d = token(0x04, "D");
1801
1802        let high_gas: u64 = 100_000_000;
1803        let low_gas: u64 = 100;
1804
1805        let (market, manager) = setup_market_bf(vec![
1806            ("component_ab", &token_a, &token_b, MockProtocolSim::new(3.0).with_gas(high_gas)),
1807            ("component_bd", &token_b, &token_d, MockProtocolSim::new(2.0).with_gas(high_gas)),
1808            ("component_ac", &token_a, &token_c, MockProtocolSim::new(2.0).with_gas(low_gas)),
1809            ("component_cd", &token_c, &token_d, MockProtocolSim::new(2.0).with_gas(low_gas)),
1810        ]);
1811
1812        let algo = bf_algorithm(3, 1000);
1813        let ord = order(&token_a, &token_d, 1_000_000_000, OrderSide::Sell);
1814
1815        // No derived data: should fall back to gross comparison
1816        let result = algo
1817            .find_best_route(manager.graph(), market, None, None, &ord)
1818            .await
1819            .unwrap();
1820
1821        // Without gas awareness, picks the higher-gross path A -> B -> D
1822        assert_eq!(result.route().swaps().len(), 2);
1823        assert_eq!(result.route().swaps()[0].component_id(), "component_ab");
1824        assert_eq!(result.route().swaps()[1].component_id(), "component_bd");
1825    }
1826
1827    #[tokio::test]
1828    async fn test_amount_too_small_when_net_uneconomic_after_gas() {
1829        // Gas-aware branch: the 1-unit input (worth 1 at 1:1) is far below the hop's
1830        // gas cost of 1000 -> AmountTooSmall.
1831        let token_a = token(0x01, "A");
1832        let token_b = token(0x02, "B");
1833
1834        let (market, manager) = setup_market_bf(vec![(
1835            "component_ab",
1836            &token_a,
1837            &token_b,
1838            MockProtocolSim::new(2.0).with_gas(10),
1839        )]);
1840
1841        let algo = bf_algorithm(2, 1000);
1842        let ord = order(&token_a, &token_b, 1, OrderSide::Sell);
1843        let derived =
1844            setup_derived_with_token_prices(&[token_a.address.clone(), token_b.address.clone()]);
1845
1846        let result = algo
1847            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1848            .await;
1849        assert!(matches!(
1850            result,
1851            Err(AlgorithmError::NoPath { reason: NoPathReason::AmountTooSmall, .. })
1852        ));
1853    }
1854
1855    #[tokio::test]
1856    async fn test_no_graph_path_when_output_uneconomic_but_input_economic() {
1857        // Output value (500) is below the hop's gas (1000) so the solve fails, but the
1858        // input (10_000) covers it: a healthy-sized order on a low-rate component must read
1859        // NoGraphPath, not AmountTooSmall.
1860        let token_a = token(0x01, "A");
1861        let token_b = token(0x02, "B");
1862
1863        let (market, manager) = setup_market_bf(vec![(
1864            "component_ab",
1865            &token_a,
1866            &token_b,
1867            MockProtocolSim::new(0.05).with_gas(10),
1868        )]);
1869
1870        let algo = bf_algorithm(1, 1000);
1871        let ord = order(&token_a, &token_b, 10_000, OrderSide::Sell);
1872        let derived =
1873            setup_derived_with_token_prices(&[token_a.address.clone(), token_b.address.clone()]);
1874
1875        let result = algo
1876            .find_best_route(manager.graph(), market, None, Some(derived), &ord)
1877            .await;
1878        assert!(matches!(
1879            result,
1880            Err(AlgorithmError::NoPath { reason: NoPathReason::NoGraphPath, .. })
1881        ));
1882    }
1883
1884    // ==================== Connector token tests ====================
1885
1886    /// Build a BellmanFord algorithm whose config includes a specific connector token allowlist.
1887    fn bf_algorithm_with_connectors(
1888        max_hops: usize,
1889        timeout_ms: u64,
1890        connector_tokens: FxHashSet<Address>,
1891    ) -> BellmanFordAlgorithm {
1892        BellmanFordAlgorithm::with_config(
1893            AlgorithmConfig::new(1, max_hops, Duration::from_millis(timeout_ms), None)
1894                .unwrap()
1895                .with_connector_tokens(connector_tokens),
1896        )
1897    }
1898
1899    #[tokio::test]
1900    async fn test_connector_tokens_blocks_disallowed_intermediate() {
1901        //      A
1902        //    /   \
1903        //   B     C   ← only C is in the allowlist
1904        //    \   /
1905        //      D
1906        // A->B->D is pruned; only A->C->D survives.
1907        let token_a = token(0x01, "A");
1908        let token_b = token(0x02, "B");
1909        let token_c = token(0x03, "C");
1910        let token_d = token(0x04, "D");
1911
1912        let (market, manager) = setup_market_bf(vec![
1913            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1914            ("component_bd", &token_b, &token_d, MockProtocolSim::new(2.0)),
1915            ("component_ac", &token_a, &token_c, MockProtocolSim::new(3.0)),
1916            ("component_cd", &token_c, &token_d, MockProtocolSim::new(3.0)),
1917        ]);
1918
1919        let connectors: FxHashSet<Address> = FxHashSet::from_iter([token_c.address.clone()]);
1920        let algo = bf_algorithm_with_connectors(3, 1000, connectors);
1921        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1922
1923        let result = algo
1924            .find_best_route(manager.graph(), market, None, None, &ord)
1925            .await
1926            .unwrap();
1927
1928        // Only A->C->D is reachable; B was pruned.
1929        assert_eq!(result.route().swaps().len(), 2);
1930        assert_eq!(result.route().swaps()[0].component_id(), "component_ac");
1931        assert_eq!(result.route().swaps()[1].component_id(), "component_cd");
1932    }
1933
1934    #[tokio::test]
1935    async fn test_connector_tokens_allows_endpoints_even_if_not_listed() {
1936        // token_in (A) and token_out (B) must be reachable even when connector list is empty.
1937        let token_a = token(0x01, "A");
1938        let token_b = token(0x02, "B");
1939
1940        let (market, manager) =
1941            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
1942
1943        // Empty allowlist — no intermediate tokens allowed, but direct hop A->B should work.
1944        let algo = bf_algorithm_with_connectors(1, 1000, FxHashSet::default());
1945        let ord = order(&token_a, &token_b, 100, OrderSide::Sell);
1946
1947        let result = algo
1948            .find_best_route(manager.graph(), market, None, None, &ord)
1949            .await
1950            .unwrap();
1951
1952        assert_eq!(result.route().swaps().len(), 1);
1953        assert_eq!(result.route().swaps()[0].amount_out(), &BigUint::from(200u64));
1954    }
1955
1956    #[tokio::test]
1957    async fn test_connector_tokens_none_is_unrestricted() {
1958        // No connector_tokens set: both A->B->D and A->C->D are evaluated.
1959        let token_a = token(0x01, "A");
1960        let token_b = token(0x02, "B");
1961        let token_c = token(0x03, "C");
1962        let token_d = token(0x04, "D");
1963
1964        let (market, manager) = setup_market_bf(vec![
1965            ("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0)),
1966            ("component_bd", &token_b, &token_d, MockProtocolSim::new(3.0)),
1967            ("component_ac", &token_a, &token_c, MockProtocolSim::new(1.0)),
1968            ("component_cd", &token_c, &token_d, MockProtocolSim::new(1.0)),
1969        ]);
1970
1971        let algo = bf_algorithm(3, 1000);
1972        let ord = order(&token_a, &token_d, 100, OrderSide::Sell);
1973
1974        let result = algo
1975            .find_best_route(manager.graph(), market, None, None, &ord)
1976            .await
1977            .unwrap();
1978
1979        // Best path is A->B->D = 100*2*3 = 600
1980        assert_eq!(result.route().swaps()[0].component_id(), "component_ab");
1981        assert_eq!(result.route().swaps()[1].component_id(), "component_bd");
1982        assert_eq!(result.route().swaps()[1].amount_out(), &BigUint::from(600u64));
1983    }
1984
1985    #[test]
1986    fn test_path_has_conflict_detects_node_and_component() {
1987        // Path: 0 -[component_a]-> 1 -[component_b]-> 2
1988        let mut pred: Vec<Option<(NodeIndex, ComponentId)>> = vec![None; 4];
1989        pred[1] = Some((NodeIndex::new(0), "component_a".into()));
1990        pred[2] = Some((NodeIndex::new(1), "component_b".into()));
1991
1992        // Node conflicts: node 0 is in path, node 3 is not
1993        assert!(BellmanFordAlgorithm::path_has_conflict(
1994            NodeIndex::new(2),
1995            NodeIndex::new(0),
1996            &"any".into(),
1997            &pred
1998        ));
1999        assert!(!BellmanFordAlgorithm::path_has_conflict(
2000            NodeIndex::new(2),
2001            NodeIndex::new(3),
2002            &"any".into(),
2003            &pred
2004        ));
2005        // Self-check: node 2 is itself in the "path from 2"
2006        assert!(BellmanFordAlgorithm::path_has_conflict(
2007            NodeIndex::new(2),
2008            NodeIndex::new(2),
2009            &"any".into(),
2010            &pred
2011        ));
2012
2013        // Component conflicts: component_a and component_b are used, component_c is not
2014        assert!(BellmanFordAlgorithm::path_has_conflict(
2015            NodeIndex::new(2),
2016            NodeIndex::new(3),
2017            &"component_a".into(),
2018            &pred
2019        ));
2020        assert!(BellmanFordAlgorithm::path_has_conflict(
2021            NodeIndex::new(2),
2022            NodeIndex::new(3),
2023            &"component_b".into(),
2024            &pred
2025        ));
2026        assert!(!BellmanFordAlgorithm::path_has_conflict(
2027            NodeIndex::new(2),
2028            NodeIndex::new(3),
2029            &"component_c".into(),
2030            &pred
2031        ));
2032    }
2033
2034    #[tokio::test]
2035    async fn test_find_single_route_with_state_overrides() {
2036        let token_a = token(0x01, "A");
2037        let token_b = token(0x02, "B");
2038
2039        let (market, manager) =
2040            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
2041
2042        let algo = bf_algorithm(2, 1000);
2043        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2044
2045        let ctx = algo
2046            .build_context(manager.graph(), market, None, None, &ord)
2047            .await
2048            .unwrap();
2049
2050        // Without overrides: 1000 * 2.0 = 2000
2051        let normal = algo
2052            .find_single_route(&ctx, &ord, FindRouteOptions::default())
2053            .unwrap();
2054        assert_eq!(normal.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
2055
2056        // Override component_ab with a degraded sim (multiplier 1.0): 1000 * 1.0 = 1000
2057        let opts = FindRouteOptions {
2058            overrides: MarketOverrides::empty()
2059                .with_override("component_ab".to_string(), Box::new(MockProtocolSim::new(1.0))),
2060        };
2061        let overridden = algo
2062            .find_single_route(&ctx, &ord, opts)
2063            .unwrap();
2064        assert_eq!(overridden.route().swaps()[0].amount_out(), &BigUint::from(1000u64));
2065
2066        assert!(
2067            overridden.route().swaps()[0].amount_out() < normal.route().swaps()[0].amount_out()
2068        );
2069    }
2070
2071    #[tokio::test]
2072    async fn test_single_find_route_options_default() {
2073        use super::super::split_primitives::MarketOverrides;
2074
2075        let token_a = token(0x01, "A");
2076        let token_b = token(0x02, "B");
2077
2078        let (market, manager) =
2079            setup_market_bf(vec![("component_ab", &token_a, &token_b, MockProtocolSim::new(2.0))]);
2080
2081        let algo = bf_algorithm(2, 1000);
2082        let ord = order(&token_a, &token_b, 1000, OrderSide::Sell);
2083
2084        let ctx = algo
2085            .build_context(manager.graph(), market, None, None, &ord)
2086            .await
2087            .unwrap();
2088
2089        let with_default = algo
2090            .find_single_route(&ctx, &ord, FindRouteOptions::default())
2091            .unwrap();
2092        let with_empty = algo
2093            .find_single_route(&ctx, &ord, FindRouteOptions { overrides: MarketOverrides::empty() })
2094            .unwrap();
2095
2096        assert_eq!(
2097            with_default.route().swaps()[0].amount_out(),
2098            with_empty.route().swaps()[0].amount_out()
2099        );
2100        assert_eq!(with_default.route().swaps()[0].amount_out(), &BigUint::from(2000u64));
2101    }
2102}