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