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}