Skip to main content

fynd_core/algorithm/
mod.rs

1//! Route-finding algorithms.
2//!
3//! This module defines the Algorithm trait and built-in implementations.
4//! New algorithms can be added by implementing the trait.
5//!
6//! Algorithms are generic over their preferred graph type, allowing them to use
7//! different graph crates (petgraph, custom, etc.) and leverage built-in algorithms.
8//!
9//! # Adding a New Algorithm
10//!
11//! **External:** Implement the `Algorithm` trait in your own crate and plug it
12//! into a [`WorkerPoolBuilder`](crate::worker_pool::pool::WorkerPoolBuilder) via
13//! [`with_algorithm`](crate::worker_pool::pool::WorkerPoolBuilder::with_algorithm). No changes
14//! to fynd-core required. See the `custom_algorithm` example.
15//!
16//! **Built-in:** To add an algorithm to the built-in registry:
17//! 1. Create a new module with your algorithm implementation
18//! 2. Implement the `Algorithm` trait
19//! 3. Register it in `registry.rs`
20
21pub mod bellman_ford;
22pub mod most_liquid;
23#[allow(dead_code)]
24pub(crate) mod split_primitives;
25
26#[cfg(test)]
27pub mod split_test_harness;
28#[cfg(test)]
29pub mod test_utils;
30
31use std::{collections::HashSet, time::Duration};
32
33pub use bellman_ford::BellmanFordAlgorithm;
34pub use most_liquid::MostLiquidAlgorithm;
35use tycho_simulation::tycho_core::models::Address;
36
37use crate::{
38    derived::{computation::ComputationRequirements, SharedDerivedDataRef},
39    feed::market_data::{MarketData, StateLabel},
40    graph::GraphManager,
41    types::{quote::Order, RouteResult},
42};
43
44/// Configuration for an Algorithm instance.
45#[must_use]
46#[derive(Debug, Clone)]
47pub struct AlgorithmConfig {
48    /// Minimum hops to search (must be >= 1).
49    min_hops: usize,
50    /// Maximum hops to search.
51    max_hops: usize,
52    /// Timeout for solving.
53    timeout: Duration,
54    /// Maximum number of paths to simulate. `None` means no cap.
55    max_routes: Option<usize>,
56    /// Enable gas-aware comparison (compares net amounts instead of gross during path selection).
57    /// Currently used by Bellman-Ford; ignored by other algorithms. Defaults to true.
58    gas_aware: bool,
59    /// Tokens allowed as intermediate hops. `None` = no restriction (all tokens reachable).
60    /// `token_in` and `token_out` for a given order are always allowed regardless.
61    connector_tokens: Option<HashSet<Address>>,
62}
63
64impl AlgorithmConfig {
65    /// Creates a new `AlgorithmConfig` with validation.
66    ///
67    /// # Errors
68    ///
69    /// Returns `InvalidConfiguration` if:
70    /// - `min_hops == 0` (at least one hop is required)
71    /// - `min_hops > max_hops`
72    /// - `max_routes` is `Some(0)`
73    pub fn new(
74        min_hops: usize,
75        max_hops: usize,
76        timeout: Duration,
77        max_routes: Option<usize>,
78    ) -> Result<Self, AlgorithmError> {
79        if min_hops == 0 {
80            return Err(AlgorithmError::InvalidConfiguration {
81                reason: "min_hops must be at least 1".to_string(),
82            });
83        }
84        if min_hops > max_hops {
85            return Err(AlgorithmError::InvalidConfiguration {
86                reason: format!("min_hops ({}) cannot exceed max_hops ({})", min_hops, max_hops),
87            });
88        }
89        if max_routes == Some(0) {
90            return Err(AlgorithmError::InvalidConfiguration {
91                reason: "max_routes must be at least 1".to_string(),
92            });
93        }
94        Ok(Self {
95            min_hops,
96            max_hops,
97            timeout,
98            max_routes,
99            gas_aware: true,
100            connector_tokens: None,
101        })
102    }
103
104    /// Returns the minimum number of hops to search.
105    pub fn min_hops(&self) -> usize {
106        self.min_hops
107    }
108
109    /// Returns the maximum number of hops to search.
110    pub fn max_hops(&self) -> usize {
111        self.max_hops
112    }
113
114    /// Returns the maximum number of paths to simulate.
115    pub fn max_routes(&self) -> Option<usize> {
116        self.max_routes
117    }
118
119    /// Returns the timeout for solving.
120    pub fn timeout(&self) -> Duration {
121        self.timeout
122    }
123
124    /// Returns whether gas-aware comparison is enabled.
125    pub fn gas_aware(&self) -> bool {
126        self.gas_aware
127    }
128
129    /// Sets gas-aware comparison.
130    pub fn with_gas_aware(mut self, enabled: bool) -> Self {
131        self.gas_aware = enabled;
132        self
133    }
134
135    /// Restricts intermediate hops to the given token set.
136    ///
137    /// When set, only these tokens may appear between `token_in` and `token_out`
138    /// in a multi-hop route. The order endpoints are always allowed regardless.
139    /// Pass an empty set to disallow all intermediate hops (only 1-hop routes possible).
140    pub fn with_connector_tokens(mut self, tokens: HashSet<Address>) -> Self {
141        self.connector_tokens = Some(tokens);
142        self
143    }
144
145    /// Returns the connector token allowlist, or `None` if all tokens are permitted.
146    pub fn connector_tokens(&self) -> Option<&HashSet<Address>> {
147        self.connector_tokens.as_ref()
148    }
149}
150
151impl Default for AlgorithmConfig {
152    fn default() -> Self {
153        // Default values are valid, so we can unwrap safely
154        Self::new(1, 3, Duration::from_millis(100), None).unwrap()
155    }
156}
157
158/// Trait for route-finding algorithms.
159///
160/// Algorithms are generic over their preferred graph type `G`, allowing them to:
161/// - Use different graph crates (petgraph, custom, etc.)
162/// - Leverage built-in algorithms from graph libraries
163/// - Optimize their graph representation for their specific needs
164///
165/// # Implementation Notes
166///
167/// - Algorithms should respect the timeout from `timeout()`
168/// - They should use `graph` for path finding (BFS/etc)
169/// - They should use `market` to read component states for simulation
170/// - They should NOT modify the graph or market data
171#[allow(async_fn_in_trait)]
172pub trait Algorithm: Send + Sync {
173    /// The graph type this algorithm uses.
174    type GraphType: Send + Sync;
175
176    /// The graph manager type for this algorithm.
177    /// This allows the solver to automatically create the appropriate graph manager.
178    type GraphManager: GraphManager<Self::GraphType> + Default;
179
180    /// Returns the algorithm's name.
181    fn name(&self) -> &str;
182
183    /// Finds the best route for the given order.
184    ///
185    /// # Arguments
186    ///
187    /// * `graph` - The algorithm's preferred graph type (e.g., petgraph::Graph)
188    /// * `market` - Shared reference to market data for state lookups (algorithms acquire their own
189    ///   locks)
190    /// * `label` - Optional overlay label; when `Some`, the algorithm reads market state through
191    ///   the named overlay so per-request pool overrides are applied during solving
192    /// * `derived` - Optional shared reference to derived data (token prices, etc.)
193    /// * `order` - The order to solve
194    ///
195    /// # Returns
196    ///
197    /// The best route and its gas-adjusted net output amount, or an error if no route could be
198    /// found.
199    async fn find_best_route(
200        &self,
201        graph: &Self::GraphType,
202        market: MarketData,
203        label: Option<StateLabel>,
204        derived: Option<SharedDerivedDataRef>,
205        order: &Order,
206    ) -> Result<RouteResult, AlgorithmError>;
207
208    /// Returns the derived data computation requirements for this algorithm.
209    ///
210    /// Algorithms declare freshness requirements for derived data:
211    /// - `require_fresh`: Data must be from the current block (same as MarketState)
212    /// - `allow_stale`: Data can be from any past block, as long as it exists
213    ///
214    /// Workers use this to determine when they can safely solve.
215    ///
216    /// Default implementation returns no requirements - algorithm works without
217    /// any derived data.
218    fn computation_requirements(&self) -> ComputationRequirements;
219
220    /// Returns the timeout for solving.
221    ///
222    /// Workers use this to set the maximum time to wait for derived data
223    /// before failing a solve request.
224    fn timeout(&self) -> Duration;
225}
226
227/// Errors that can occur during route finding.
228#[non_exhaustive]
229#[derive(Debug, Clone, thiserror::Error, PartialEq)]
230pub enum AlgorithmError {
231    /// Invalid algorithm configuration (programmer error).
232    #[non_exhaustive]
233    #[error("invalid configuration: {reason}")]
234    InvalidConfiguration {
235        /// Human-readable description of the invalid configuration.
236        reason: String,
237    },
238
239    /// No path exists between the tokens.
240    #[non_exhaustive]
241    #[error("no path from {from:?} to {to:?}: {reason}")]
242    NoPath {
243        /// Input token address.
244        from: Address,
245        /// Output token address.
246        to: Address,
247        /// Detailed reason why no path was found.
248        reason: NoPathReason,
249    },
250
251    /// Paths exist but none have sufficient liquidity.
252    #[error("insufficient liquidity on all paths")]
253    InsufficientLiquidity,
254
255    /// Route finding timed out.
256    #[non_exhaustive]
257    #[error("timeout after {elapsed_ms}ms")]
258    Timeout {
259        /// Elapsed time in milliseconds when the timeout fired.
260        elapsed_ms: u64,
261    },
262
263    /// Exact-out not supported by this algorithm.
264    #[error("exact-out orders not supported")]
265    ExactOutNotSupported,
266
267    /// Simulation failed for a specific component.
268    #[non_exhaustive]
269    #[error("simulation failed for {component_id}: {error}")]
270    SimulationFailed {
271        /// ID of the pool component that failed.
272        component_id: String,
273        /// Underlying simulation error message.
274        error: String,
275    },
276
277    /// Required data not found in market.
278    #[non_exhaustive]
279    #[error("{kind} not found{}", id.as_ref().map(|i| format!(": {i}")).unwrap_or_default())]
280    DataNotFound {
281        /// Category of the missing data (e.g. `"token"`, `"component"`).
282        kind: &'static str,
283        /// Optional identifier of the missing item.
284        id: Option<String>,
285    },
286
287    /// Other algorithm-specific error.
288    #[error("{0}")]
289    Other(String),
290}
291
292/// Reason why no path was found between tokens.
293#[non_exhaustive]
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub enum NoPathReason {
296    /// Source token not present in the routing graph.
297    SourceTokenNotInGraph,
298    /// Destination token not present in the routing graph.
299    DestinationTokenNotInGraph,
300    /// Both tokens exist but no edges connect them within hop limits.
301    NoGraphPath,
302    /// Paths exist but none could be scored (e.g., missing edge weights).
303    NoScorablePaths,
304}
305
306impl std::fmt::Display for NoPathReason {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        match self {
309            Self::SourceTokenNotInGraph => write!(f, "source token not in graph"),
310            Self::DestinationTokenNotInGraph => write!(f, "destination token not in graph"),
311            Self::NoGraphPath => write!(f, "no connecting path in graph"),
312            Self::NoScorablePaths => write!(f, "no paths with valid scores"),
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_connector_tokens_default_is_none() {
323        assert!(AlgorithmConfig::default()
324            .connector_tokens()
325            .is_none());
326    }
327
328    #[test]
329    fn test_with_connector_tokens_sets_field() {
330        let addr = Address::from([0x01u8; 20]);
331        let tokens: HashSet<Address> = [addr.clone()].into();
332        let config = AlgorithmConfig::default().with_connector_tokens(tokens);
333        let stored = config
334            .connector_tokens()
335            .expect("should be Some");
336        assert!(stored.contains(&addr));
337        assert_eq!(stored.len(), 1);
338    }
339
340    #[test]
341    fn test_with_connector_tokens_empty_set() {
342        let config = AlgorithmConfig::default().with_connector_tokens(HashSet::new());
343        assert_eq!(
344            config
345                .connector_tokens()
346                .map(|s| s.len()),
347            Some(0)
348        );
349    }
350}