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