Skip to main content

fynd_core/algorithm/
bellman_ford.rs

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