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