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