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