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