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