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