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