Skip to main content

fynd_core/
solver.rs

1//! High-level solver setup via [`FyndBuilder`].
2//!
3//! [`FyndBuilder`] assembles the full Tycho feed + gas fetcher + computation
4//! manager + one or more worker pools + encoder + router pipeline with sensible
5//! defaults. For simple cases a single call chain is all that's needed:
6//!
7//! ```ignore
8//! let solver = FyndBuilder::new(chain, tycho_url, rpc_url, protocols, min_tvl)
9//!     .tycho_api_key(key)
10//!     .algorithm("most_liquid")
11//!     .build()?;
12//! ```
13use std::{collections::HashSet, str::FromStr, sync::Arc, time::Duration};
14
15use num_cpus;
16use serde::{Deserialize, Serialize};
17use tokio::{sync::broadcast, task::JoinHandle};
18use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
19#[cfg(feature = "experimental")]
20use tycho_simulation::evm::stream::BlockStepController;
21#[cfg(feature = "test-utils")]
22use tycho_simulation::tycho_ethereum::gas::{BlockGasPrice, GasPrice};
23use tycho_simulation::{
24    evm::pending::PendingBlockProcessor,
25    tycho_common::{models::Chain, traits::TxDeltaIndexer, Bytes},
26    tycho_core::models::Address,
27    tycho_ethereum::rpc::EthereumRpcClient,
28};
29
30use crate::{
31    algorithm::{AlgorithmConfig, AlgorithmError},
32    derived::{ComputationManager, ComputationManagerConfig, SharedDerivedDataRef},
33    encoding::{encoder::Encoder, fee_fetcher::RouterFeeFetcher, router_fees::SharedRouterFees},
34    feed::{
35        events::{MarketEvent, MarketEventHandler},
36        gas::GasPriceFetcher,
37        market_data::MarketData,
38        tycho_feed::TychoFeed,
39        TychoFeedConfig,
40    },
41    graph::EdgeWeightUpdaterWithDerived,
42    price_guard::{
43        guard::PriceGuard, provider::PriceProvider, provider_registry::PriceProviderRegistry,
44    },
45    types::constants::native_token,
46    worker_pool::{
47        pool::{WorkerPool, WorkerPoolBuilder},
48        registry::UnknownAlgorithmError,
49    },
50    worker_pool_router::{config::WorkerPoolRouterConfig, SolverPoolHandle, WorkerPoolRouter},
51    Algorithm, Quote, QuoteRequest, SolveError,
52};
53
54/// Default values for [`FyndBuilder`] configuration and [`PoolConfig`] deserialization.
55///
56/// These are the single source of truth for all tunable defaults. Downstream
57/// crates (e.g. `fynd-rpc`) should re-export or reference these rather than
58/// redeclaring their own copies.
59pub mod defaults {
60    use std::time::Duration;
61
62    /// Minimum token quality score required for a token to be included in routing.
63    pub const MIN_TOKEN_QUALITY: i32 = 100;
64    /// Maximum age (in days) of trading history required for a token to be considered liquid.
65    pub const TRADED_N_DAYS_AGO: u64 = 3;
66    /// Multiplier applied to a pool's TVL when estimating available liquidity.
67    pub const TVL_BUFFER_RATIO: f64 = 1.1;
68    /// How often the gas price is refreshed from the RPC node.
69    pub const GAS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
70    /// How often router fees are refreshed from the on-chain FeeCalculator contract.
71    pub const ROUTER_FEE_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
72    /// Delay before reconnecting to the Tycho feed after a disconnect.
73    pub const RECONNECT_DELAY: Duration = Duration::from_secs(5);
74    /// Minimum number of solver pool responses required before returning a quote (`0` = wait for
75    /// all).
76    pub const ROUTER_MIN_RESPONSES: usize = 0;
77    /// Capacity of the task queue for each worker pool.
78    pub const POOL_TASK_QUEUE_CAPACITY: usize = 1000;
79    /// Minimum number of hops allowed in a route.
80    pub const POOL_MIN_HOPS: usize = 1;
81    /// Maximum number of hops allowed in a route.
82    pub const POOL_MAX_HOPS: usize = 3;
83    /// Per-pool solve timeout in milliseconds.
84    pub const POOL_TIMEOUT_MS: u64 = 100;
85}
86
87// Internal-only defaults not shared with downstream crates.
88const DEFAULT_TYCHO_USE_TLS: bool = true;
89const DEFAULT_DEPTH_SLIPPAGE_THRESHOLD: f64 = 0.01;
90/// Generous router timeout for standalone (non-server) use. HTTP services should
91/// override this to a tighter value appropriate for their SLA.
92const DEFAULT_ROUTER_TIMEOUT: Duration = Duration::from_secs(10);
93
94// serde requires free functions for `#[serde(default = "...")]` — these delegate to the
95// defaults module so both deserialization and the builder stay in sync.
96fn default_task_queue_capacity() -> usize {
97    defaults::POOL_TASK_QUEUE_CAPACITY
98}
99
100fn default_min_hops() -> usize {
101    defaults::POOL_MIN_HOPS
102}
103
104fn default_max_hops() -> usize {
105    defaults::POOL_MAX_HOPS
106}
107
108fn default_algo_timeout_ms() -> u64 {
109    defaults::POOL_TIMEOUT_MS
110}
111
112fn parse_connector_tokens(
113    raw: Option<&[String]>,
114) -> Result<Option<HashSet<Address>>, SolverBuildError> {
115    let Some(strings) = raw else {
116        return Ok(None);
117    };
118    let mut set = HashSet::with_capacity(strings.len());
119    for s in strings {
120        let addr = Address::from_str(s).map_err(|e| AlgorithmError::InvalidConfiguration {
121            reason: format!("connector_tokens: invalid address {s:?}: {e}"),
122        })?;
123        set.insert(addr);
124    }
125    Ok(Some(set))
126}
127
128/// Per-pool configuration for [`FyndBuilder::add_pool`].
129#[must_use]
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PoolConfig {
132    /// Algorithm name for this pool (e.g., `"most_liquid"`).
133    algorithm: String,
134    /// Number of worker threads for this pool.
135    #[serde(default = "num_cpus::get")]
136    num_workers: usize,
137    /// Task queue capacity for this pool.
138    #[serde(default = "default_task_queue_capacity")]
139    task_queue_capacity: usize,
140    /// Minimum hops to search (must be >= 1).
141    #[serde(default = "default_min_hops")]
142    min_hops: usize,
143    /// Maximum hops to search.
144    #[serde(default = "default_max_hops")]
145    max_hops: usize,
146    /// Timeout for solving in milliseconds.
147    #[serde(default = "default_algo_timeout_ms")]
148    timeout_ms: u64,
149    /// Maximum number of paths to simulate per solve. `None` simulates all scored paths.
150    #[serde(default)]
151    max_routes: Option<usize>,
152    /// Lowercase hex addresses (e.g. `"0xc02aaa…"`) allowed as intermediate routing hops.
153    /// Absent = no restriction. Typically 3–10 entries (e.g. WETH, USDC, USDT, DAI).
154    #[serde(default)]
155    connector_tokens: Option<Vec<String>>,
156}
157
158impl PoolConfig {
159    /// Creates a new pool config with the given algorithm name and defaults for all other fields.
160    pub fn new(algorithm: impl Into<String>) -> Self {
161        Self {
162            algorithm: algorithm.into(),
163            num_workers: num_cpus::get(),
164            task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
165            min_hops: defaults::POOL_MIN_HOPS,
166            max_hops: defaults::POOL_MAX_HOPS,
167            timeout_ms: defaults::POOL_TIMEOUT_MS,
168            max_routes: None,
169            connector_tokens: None,
170        }
171    }
172
173    /// Returns the algorithm name.
174    pub fn algorithm(&self) -> &str {
175        &self.algorithm
176    }
177
178    /// Returns the number of worker threads.
179    pub fn num_workers(&self) -> usize {
180        self.num_workers
181    }
182
183    /// Sets the number of worker threads.
184    pub fn with_num_workers(mut self, num_workers: usize) -> Self {
185        self.num_workers = num_workers;
186        self
187    }
188
189    /// Sets the task queue capacity.
190    pub fn with_task_queue_capacity(mut self, task_queue_capacity: usize) -> Self {
191        self.task_queue_capacity = task_queue_capacity;
192        self
193    }
194
195    /// Sets the minimum hops.
196    pub fn with_min_hops(mut self, min_hops: usize) -> Self {
197        self.min_hops = min_hops;
198        self
199    }
200
201    /// Sets the maximum hops.
202    pub fn with_max_hops(mut self, max_hops: usize) -> Self {
203        self.max_hops = max_hops;
204        self
205    }
206
207    /// Sets the timeout in milliseconds.
208    pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
209        self.timeout_ms = timeout_ms;
210        self
211    }
212
213    /// Sets the maximum number of routes to simulate.
214    pub fn with_max_routes(mut self, max_routes: Option<usize>) -> Self {
215        self.max_routes = max_routes;
216        self
217    }
218
219    /// Returns the task queue capacity.
220    pub fn task_queue_capacity(&self) -> usize {
221        self.task_queue_capacity
222    }
223
224    /// Returns the minimum hops.
225    pub fn min_hops(&self) -> usize {
226        self.min_hops
227    }
228
229    /// Returns the maximum hops.
230    pub fn max_hops(&self) -> usize {
231        self.max_hops
232    }
233
234    /// Returns the timeout in milliseconds.
235    pub fn timeout_ms(&self) -> u64 {
236        self.timeout_ms
237    }
238
239    /// Returns the maximum number of routes to simulate.
240    pub fn max_routes(&self) -> Option<usize> {
241        self.max_routes
242    }
243
244    /// Restricts intermediate hops to the given token addresses (hex strings with or without `0x`
245    /// prefix). Absent = no restriction.
246    pub fn with_connector_tokens(mut self, tokens: Vec<String>) -> Self {
247        self.connector_tokens = Some(tokens);
248        self
249    }
250
251    /// Returns the raw connector token address strings, or `None` if unrestricted.
252    pub fn connector_tokens(&self) -> Option<&[String]> {
253        self.connector_tokens.as_deref()
254    }
255}
256
257/// Error returned by [`Solver::wait_until_ready`].
258#[derive(Debug, thiserror::Error)]
259#[error("timed out after {timeout_ms}ms waiting for market data and derived computations")]
260pub struct WaitReadyError {
261    timeout_ms: u64,
262}
263
264/// Error returned by [`FyndBuilder::build`] and [`FyndBuilder::build_with_pending`].
265#[non_exhaustive]
266#[derive(Debug, thiserror::Error)]
267pub enum SolverBuildError {
268    /// The Ethereum RPC client could not be created (e.g. malformed URL).
269    #[error("failed to create ethereum RPC client: {0}")]
270    RpcClient(String),
271    /// An invalid algorithm configuration was supplied.
272    #[error(transparent)]
273    AlgorithmConfig(#[from] AlgorithmError),
274    /// The [`ComputationManager`] failed to initialise.
275    #[error("failed to create computation manager: {0}")]
276    ComputationManager(String),
277    /// The swap encoder could not be created for the target chain.
278    #[error("failed to create encoder: {0}")]
279    Encoder(String),
280    /// The router fee fetcher could not be created (e.g. malformed RPC URL).
281    #[error("failed to create router fee fetcher: {0}")]
282    RouterFeeFetcher(String),
283    /// A pool referenced an algorithm name that is not registered.
284    #[error(transparent)]
285    UnknownAlgorithm(#[from] UnknownAlgorithmError),
286    /// No native gas token is defined for the requested chain.
287    #[error("gas token not configured for chain")]
288    GasToken,
289    /// [`FyndBuilder::build`] was called without configuring any worker pools.
290    #[error("no worker pools configured")]
291    NoPools,
292    /// A recorded update failed to replay through the feed.
293    #[cfg(feature = "test-utils")]
294    #[error("replay failed: {0}")]
295    Replay(String),
296    /// The feed task failed before delivering the [`PendingBlockProcessor`].
297    ///
298    /// The inner string is the `DataFeedError` message from `TychoFeed::run_with_pending`
299    /// (e.g. "failed to load tokens: connection refused").
300    #[error("feed setup failed before delivering pending processor: {0}")]
301    FeedSetup(String),
302    /// The pending-processor oneshot closed without delivering a value, meaning the feed task
303    /// panicked rather than returning an error through the channel.
304    #[error("pending processor channel closed before processor was delivered")]
305    PendingChannelClosed,
306    /// The step-controller oneshot closed without delivering a value, meaning the feed task
307    /// panicked rather than returning an error through the channel.
308    #[cfg(feature = "experimental")]
309    #[error("step controller channel closed before controller was delivered")]
310    StepControllerChannelClosed,
311}
312
313/// Internal pool entry — either a built-in algorithm (by name) or a custom one.
314enum PoolEntry {
315    BuiltIn {
316        name: String,
317        algorithm: String,
318        num_workers: usize,
319        task_queue_capacity: usize,
320        min_hops: usize,
321        max_hops: usize,
322        timeout_ms: u64,
323        max_routes: Option<usize>,
324        connector_tokens: Option<HashSet<Address>>,
325    },
326    Custom(CustomPoolEntry),
327}
328
329/// Pool entry backed by a custom [`Algorithm`] implementation.
330struct CustomPoolEntry {
331    name: String,
332    num_workers: usize,
333    task_queue_capacity: usize,
334    min_hops: usize,
335    max_hops: usize,
336    timeout_ms: u64,
337    max_routes: Option<usize>,
338    /// Applies the custom algorithm to a `WorkerPoolBuilder`.
339    configure: Box<dyn FnOnce(WorkerPoolBuilder) -> WorkerPoolBuilder + Send>,
340}
341
342/// All components produced by [`FyndBuilder::assemble_components`], consumed by
343/// [`FyndBuilder::build`] and [`FyndBuilder::build_with_pending`].
344struct BuiltComponents {
345    tycho_feed: TychoFeed,
346    gas_price_fetcher: GasPriceFetcher<EthereumRpcClient>,
347    router_fee_fetcher: RouterFeeFetcher,
348    computation_manager: ComputationManager,
349    computation_event_rx: broadcast::Receiver<MarketEvent>,
350    computation_shutdown_tx: broadcast::Sender<()>,
351    computation_shutdown_rx: broadcast::Receiver<()>,
352    router: WorkerPoolRouter,
353    worker_pools: Vec<WorkerPool>,
354    market_data: MarketData,
355    derived_data: SharedDerivedDataRef,
356    router_fees: SharedRouterFees,
357    chain: Chain,
358    router_address: Bytes,
359    pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
360    market_event_tx: broadcast::Sender<MarketEvent>,
361}
362
363/// Builder for assembling the full solver pipeline.
364///
365/// Configures the Tycho market-data feed, gas price fetcher, derived-data
366/// computation manager, one or more worker pools, encoder, and router.
367#[must_use = "a builder does nothing until .build() is called"]
368pub struct FyndBuilder {
369    chain: Chain,
370    tycho_url: String,
371    rpc_url: String,
372    protocols: Vec<String>,
373    min_tvl: f64,
374    tycho_api_key: Option<String>,
375    tycho_use_tls: bool,
376    min_token_quality: i32,
377    traded_n_days_ago: u64,
378    tvl_buffer_ratio: f64,
379    gas_refresh_interval: Duration,
380    reconnect_delay: Duration,
381    blocklisted_components: HashSet<String>,
382    partial_blocks: bool,
383    router_timeout: Duration,
384    router_min_responses: usize,
385    encoder: Option<Encoder>,
386    pools: Vec<PoolEntry>,
387    price_guard_enabled: bool,
388    price_providers: Vec<Box<dyn PriceProvider>>,
389    pending_indexers: Vec<(String, Box<dyn TxDeltaIndexer>)>,
390}
391
392impl FyndBuilder {
393    /// Creates a new builder with the required parameters.
394    pub fn new(
395        chain: Chain,
396        tycho_url: impl Into<String>,
397        rpc_url: impl Into<String>,
398        protocols: Vec<String>,
399        min_tvl: f64,
400    ) -> Self {
401        Self {
402            chain,
403            tycho_url: tycho_url.into(),
404            rpc_url: rpc_url.into(),
405            protocols,
406            min_tvl,
407            tycho_api_key: None,
408            tycho_use_tls: DEFAULT_TYCHO_USE_TLS,
409            min_token_quality: defaults::MIN_TOKEN_QUALITY,
410            traded_n_days_ago: defaults::TRADED_N_DAYS_AGO,
411            tvl_buffer_ratio: defaults::TVL_BUFFER_RATIO,
412            gas_refresh_interval: defaults::GAS_REFRESH_INTERVAL,
413            reconnect_delay: defaults::RECONNECT_DELAY,
414            blocklisted_components: HashSet::new(),
415            partial_blocks: false,
416            router_timeout: DEFAULT_ROUTER_TIMEOUT,
417            router_min_responses: defaults::ROUTER_MIN_RESPONSES,
418            encoder: None,
419            pools: Vec::new(),
420            price_guard_enabled: false,
421            price_providers: Vec::new(),
422            pending_indexers: Vec::new(),
423        }
424    }
425
426    /// The blockchain this builder is configured for.
427    pub fn chain(&self) -> Chain {
428        self.chain
429    }
430
431    /// Sets the Tycho API key.
432    pub fn tycho_api_key(mut self, key: impl Into<String>) -> Self {
433        self.tycho_api_key = Some(key.into());
434        self
435    }
436
437    /// Overrides the minimum TVL filter set in [`FyndBuilder::new`].
438    pub fn min_tvl(mut self, min_tvl: f64) -> Self {
439        self.min_tvl = min_tvl;
440        self
441    }
442
443    /// Enables or disables TLS for the Tycho WebSocket connection (default: `true`).
444    pub fn tycho_use_tls(mut self, use_tls: bool) -> Self {
445        self.tycho_use_tls = use_tls;
446        self
447    }
448
449    /// Sets the minimum token quality score; tokens below this threshold are excluded (default:
450    /// 100).
451    pub fn min_token_quality(mut self, quality: i32) -> Self {
452        self.min_token_quality = quality;
453        self
454    }
455
456    /// Filters out pools whose last trade is older than `days` days (default: 3).
457    pub fn traded_n_days_ago(mut self, days: u64) -> Self {
458        self.traded_n_days_ago = days;
459        self
460    }
461
462    /// Multiplies reported TVL by `ratio` before applying the `min_tvl` filter (default: 1.1).
463    pub fn tvl_buffer_ratio(mut self, ratio: f64) -> Self {
464        self.tvl_buffer_ratio = ratio;
465        self
466    }
467
468    /// Sets how often the gas price is refreshed from the RPC node (default: 30 s).
469    pub fn gas_refresh_interval(mut self, interval: Duration) -> Self {
470        self.gas_refresh_interval = interval;
471        self
472    }
473
474    /// Sets the delay before reconnecting to Tycho after a disconnection (default: 5 s).
475    pub fn reconnect_delay(mut self, delay: Duration) -> Self {
476        self.reconnect_delay = delay;
477        self
478    }
479
480    /// Sets component IDs to exclude from the Tycho stream.
481    pub fn blocklisted_components(mut self, components: HashSet<String>) -> Self {
482        self.blocklisted_components = components;
483        self
484    }
485
486    /// Enables partial block (flashblock) updates from the Tycho stream (default: `false`).
487    ///
488    /// When enabled, the stream delivers pool state updates mid-block rather than only at
489    /// finalization, reducing latency. Only supported for on-chain protocols; RFQ streams are
490    /// unaffected.
491    pub fn partial_blocks(mut self, enabled: bool) -> Self {
492        self.partial_blocks = enabled;
493        self
494    }
495
496    /// Sets the worker router timeout (default: 10s).
497    pub fn worker_router_timeout(mut self, timeout: Duration) -> Self {
498        self.router_timeout = timeout;
499        self
500    }
501
502    /// Sets the minimum number of solver responses before early return (default: 0).
503    pub fn worker_router_min_responses(mut self, min: usize) -> Self {
504        self.router_min_responses = min;
505        self
506    }
507
508    /// Overrides the default encoder.
509    pub fn encoder(mut self, encoder: Encoder) -> Self {
510        self.encoder = Some(encoder);
511        self
512    }
513
514    /// Shorthand: adds a single pool named `"default"` using a built-in algorithm by name.
515    pub fn algorithm(mut self, algorithm: impl Into<String>) -> Self {
516        self.pools.push(PoolEntry::BuiltIn {
517            name: "default".to_string(),
518            algorithm: algorithm.into(),
519            num_workers: num_cpus::get(),
520            task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
521            min_hops: defaults::POOL_MIN_HOPS,
522            max_hops: defaults::POOL_MAX_HOPS,
523            timeout_ms: defaults::POOL_TIMEOUT_MS,
524            max_routes: None,
525            connector_tokens: None,
526        });
527        self
528    }
529
530    /// Shorthand: adds a single pool with a custom [`Algorithm`] implementation.
531    ///
532    /// The `factory` closure is called once per worker thread.
533    pub fn with_algorithm<A, F>(mut self, name: impl Into<String>, factory: F) -> Self
534    where
535        A: Algorithm + 'static,
536        A::GraphManager: MarketEventHandler + EdgeWeightUpdaterWithDerived + 'static,
537        F: Fn(AlgorithmConfig) -> A + Clone + Send + Sync + 'static,
538    {
539        let name = name.into();
540        let algo_name = name.clone();
541        let configure =
542            Box::new(move |builder: WorkerPoolBuilder| builder.with_algorithm(algo_name, factory));
543        self.pools
544            .push(PoolEntry::Custom(CustomPoolEntry {
545                name,
546                num_workers: num_cpus::get(),
547                task_queue_capacity: defaults::POOL_TASK_QUEUE_CAPACITY,
548                min_hops: defaults::POOL_MIN_HOPS,
549                max_hops: defaults::POOL_MAX_HOPS,
550                timeout_ms: defaults::POOL_TIMEOUT_MS,
551                max_routes: None,
552                configure,
553            }));
554        self
555    }
556
557    /// Registers the built-in price providers (Hyperliquid + Binance).
558    ///
559    /// Called automatically during [`build`](Self::build) if no providers have been
560    /// registered and the price guard is not disabled. To use only custom
561    /// providers, call [`register_price_provider`](Self::register_price_provider)
562    /// before `build()` and the defaults will be skipped.
563    pub fn add_default_price_providers(self) -> Self {
564        self.register_price_provider(Box::new(
565            crate::price_guard::hyperliquid::HyperliquidProvider::default(),
566        ))
567        .register_price_provider(Box::new(
568            crate::price_guard::binance_ws::BinanceWsProvider::default(),
569        ))
570    }
571
572    /// Registers a custom price provider for the price guard.
573    ///
574    /// The provider's [`start`](PriceProvider::start) method is called during
575    /// [`build`](Self::build) with the shared market data.
576    pub fn register_price_provider(mut self, provider: Box<dyn PriceProvider>) -> Self {
577        self.price_providers.push(provider);
578        self
579    }
580
581    /// Registers a [`TxDeltaIndexer`] for ephemeral pending-block simulation.
582    ///
583    /// `extractor` is the protocol synchronizer name (e.g. `"uniswap_v3"`). Only has effect
584    /// when calling [`build_with_pending`](Self::build_with_pending). VM protocols (prefix
585    /// `"vm:"`) are rejected by the underlying stream builder at build time.
586    pub fn with_pending_indexer(
587        mut self,
588        extractor: impl Into<String>,
589        indexer: Box<dyn TxDeltaIndexer>,
590    ) -> Self {
591        self.pending_indexers
592            .push((extractor.into(), indexer));
593        self
594    }
595
596    /// Enables or disables the price guard.
597    ///
598    /// When enabled, providers are started and caches stay warm. Validation
599    /// only runs for requests where the client sets `enabled: true` in
600    /// `PriceGuardConfig`. When disabled, no providers are started and
601    /// per-request attempts to use the guard return an error.
602    pub fn price_guard_enabled(mut self, enabled: bool) -> Self {
603        self.price_guard_enabled = enabled;
604        self
605    }
606
607    /// Adds a named pool using the given [`PoolConfig`].
608    ///
609    /// # Errors
610    ///
611    /// Returns [`SolverBuildError::AlgorithmConfig`] if any address in `connector_tokens` is not
612    /// valid hex.
613    pub fn add_pool(
614        mut self,
615        name: impl Into<String>,
616        config: &PoolConfig,
617    ) -> Result<Self, SolverBuildError> {
618        let connector_tokens = parse_connector_tokens(config.connector_tokens())?;
619        self.pools.push(PoolEntry::BuiltIn {
620            name: name.into(),
621            algorithm: config.algorithm().to_string(),
622            num_workers: config.num_workers(),
623            task_queue_capacity: config.task_queue_capacity(),
624            min_hops: config.min_hops(),
625            max_hops: config.max_hops(),
626            timeout_ms: config.timeout_ms(),
627            max_routes: config.max_routes(),
628            connector_tokens,
629        });
630        Ok(self)
631    }
632
633    /// Constructs all components shared between [`build`](Self::build) and
634    /// [`build_with_pending`](Self::build_with_pending).
635    fn assemble_components(mut self) -> Result<BuiltComponents, SolverBuildError> {
636        if self.pools.is_empty() {
637            return Err(SolverBuildError::NoPools);
638        }
639
640        // Add built-in providers if none were explicitly registered.
641        if self.price_providers.is_empty() {
642            self = self.add_default_price_providers();
643        }
644
645        let market_data = MarketData::new_shared();
646
647        let tycho_feed_config = TychoFeedConfig::new(
648            self.tycho_url,
649            self.chain,
650            self.tycho_api_key,
651            self.tycho_use_tls,
652            self.protocols,
653            self.min_tvl,
654        )
655        .tvl_buffer_ratio(self.tvl_buffer_ratio)
656        .reconnect_delay(self.reconnect_delay)
657        .min_token_quality(self.min_token_quality)
658        .traded_n_days_ago(self.traded_n_days_ago)
659        .blocklisted_components(self.blocklisted_components)
660        .partial_blocks(self.partial_blocks);
661
662        let ethereum_client = EthereumRpcClient::new(self.rpc_url.as_str())
663            .map_err(|e| SolverBuildError::RpcClient(e.to_string()))?;
664
665        let gas_price_fetcher =
666            GasPriceFetcher::new(ethereum_client, market_data.clone(), self.gas_refresh_interval);
667
668        let tycho_feed = TychoFeed::new(tycho_feed_config, market_data.clone());
669        let market_event_tx = tycho_feed.event_sender();
670
671        let gas_token = native_token(&self.chain).map_err(|_| SolverBuildError::GasToken)?;
672        let computation_config = ComputationManagerConfig::new()
673            .with_gas_token(gas_token)
674            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
675        // ComputationManager::new returns a broadcast receiver that we don't need here —
676        // workers subscribe via computation_manager.event_sender() below.
677        let (computation_manager, _) =
678            ComputationManager::new(computation_config, market_data.clone())
679                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
680
681        let derived_data: SharedDerivedDataRef = computation_manager.store();
682        let derived_event_tx = computation_manager.event_sender();
683
684        // Subscribe event channels before spawning (one for computation manager + one per pool)
685        let computation_event_rx = tycho_feed.subscribe();
686        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
687
688        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
689        let mut worker_pools: Vec<WorkerPool> = Vec::new();
690
691        for pool_entry in self.pools {
692            let pool_event_rx = tycho_feed.subscribe();
693            let derived_rx = derived_event_tx.subscribe();
694
695            let (worker_pool, task_handle) = match pool_entry {
696                PoolEntry::BuiltIn {
697                    name,
698                    algorithm,
699                    num_workers,
700                    task_queue_capacity,
701                    min_hops,
702                    max_hops,
703                    timeout_ms,
704                    max_routes,
705                    connector_tokens,
706                } => {
707                    let mut algo_cfg = AlgorithmConfig::new(
708                        min_hops,
709                        max_hops,
710                        Duration::from_millis(timeout_ms),
711                        max_routes,
712                    )?;
713                    if let Some(tokens) = connector_tokens {
714                        algo_cfg = algo_cfg.with_connector_tokens(tokens);
715                    }
716                    WorkerPoolBuilder::new()
717                        .name(name)
718                        .algorithm(algorithm)
719                        .algorithm_config(algo_cfg)
720                        .num_workers(num_workers)
721                        .task_queue_capacity(task_queue_capacity)
722                        .build(
723                            market_data.clone(),
724                            Arc::clone(&derived_data),
725                            pool_event_rx,
726                            derived_rx,
727                        )?
728                }
729                PoolEntry::Custom(custom) => {
730                    let algo_cfg = AlgorithmConfig::new(
731                        custom.min_hops,
732                        custom.max_hops,
733                        Duration::from_millis(custom.timeout_ms),
734                        custom.max_routes,
735                    )?;
736                    let builder = WorkerPoolBuilder::new()
737                        .name(custom.name)
738                        .algorithm_config(algo_cfg)
739                        .num_workers(custom.num_workers)
740                        .task_queue_capacity(custom.task_queue_capacity);
741                    let builder = (custom.configure)(builder);
742                    builder.build(
743                        market_data.clone(),
744                        Arc::clone(&derived_data),
745                        pool_event_rx,
746                        derived_rx,
747                    )?
748                }
749            };
750
751            solver_pool_handles.push(SolverPoolHandle::new(worker_pool.name(), task_handle));
752            worker_pools.push(worker_pool);
753        }
754
755        let encoder = match self.encoder {
756            Some(enc) => enc,
757            None => {
758                let registry = SwapEncoderRegistry::new(self.chain)
759                    .add_default_encoders(None)
760                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
761                Encoder::new(self.chain, registry)
762                    .map_err(|e| SolverBuildError::Encoder(e.to_string()))?
763            }
764        };
765
766        let chain = self.chain;
767        let router_address = encoder.router_address().clone();
768        let router_fees = encoder.router_fees();
769
770        let router_fee_fetcher = RouterFeeFetcher::new(
771            self.rpc_url.as_str(),
772            &router_address,
773            router_fees.clone(),
774            defaults::ROUTER_FEE_REFRESH_INTERVAL,
775        )
776        .map_err(|e| SolverBuildError::RouterFeeFetcher(e.to_string()))?;
777
778        // Only start price providers when the guard is enabled.
779        // When disabled, per-request attempts to enable the guard return an error.
780        let router_config = WorkerPoolRouterConfig::default()
781            .with_timeout(self.router_timeout)
782            .with_min_responses(self.router_min_responses);
783        let mut router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
784
785        if self.price_guard_enabled {
786            let mut registry = PriceProviderRegistry::new();
787            let mut worker_handles = Vec::new();
788            for mut provider in self.price_providers {
789                worker_handles.push(provider.start(market_data.clone()));
790                registry = registry.register(provider);
791            }
792            let price_guard = PriceGuard::new(registry, worker_handles);
793            router = router.with_price_guard(price_guard);
794        }
795
796        Ok(BuiltComponents {
797            tycho_feed,
798            gas_price_fetcher,
799            router_fee_fetcher,
800            computation_manager,
801            computation_event_rx,
802            computation_shutdown_tx,
803            computation_shutdown_rx,
804            router,
805            worker_pools,
806            market_data,
807            derived_data,
808            router_fees,
809            chain,
810            router_address,
811            pending_indexers: self.pending_indexers,
812            market_event_tx,
813        })
814    }
815
816    /// Assembles and starts all solver components.
817    ///
818    /// # Errors
819    ///
820    /// Returns [`SolverBuildError`] if any component fails to initialize.
821    pub fn build(self) -> Result<Solver, SolverBuildError> {
822        let mut c = self.assemble_components()?;
823
824        let feed_handle = tokio::spawn(async move {
825            if let Err(e) = c.tycho_feed.run().await {
826                tracing::error!(error = %e, "tycho feed error");
827            }
828        });
829        let gas_price_handle = tokio::spawn(async move {
830            c.gas_price_fetcher.run().await;
831        });
832        let router_fee_handle = tokio::spawn(async move {
833            c.router_fee_fetcher.run().await;
834        });
835        let computation_handle = tokio::spawn(async move {
836            c.computation_manager
837                .run(c.computation_event_rx, c.computation_shutdown_rx)
838                .await;
839        });
840
841        Ok(Solver {
842            router: c.router,
843            worker_pools: c.worker_pools,
844            market_data: c.market_data,
845            derived_data: c.derived_data,
846            router_fees: c.router_fees,
847            feed_handle,
848            gas_price_handle,
849            router_fee_handle,
850            computation_handle,
851            computation_shutdown_tx: c.computation_shutdown_tx,
852            chain: c.chain,
853            router_address: c.router_address,
854            market_event_tx: c.market_event_tx,
855        })
856    }
857
858    /// Assembles and starts all solver components, also returning a [`PendingBlockProcessor`]
859    /// for ephemeral bundle simulation against the live Tycho market state.
860    ///
861    /// Identical to [`build`](Self::build) except the feed task runs via
862    /// `TychoFeed::run_with_pending`. The `PendingBlockProcessor` is delivered after
863    /// token loading, before the first block is processed.
864    ///
865    /// # Errors
866    ///
867    /// Returns [`SolverBuildError`] if any component fails to initialize or the pending
868    /// channel closes before delivering the processor (e.g. token loading failed).
869    pub async fn build_with_pending(
870        self,
871    ) -> Result<(Solver, PendingBlockProcessor), SolverBuildError> {
872        let mut c = self.assemble_components()?;
873
874        let (pending_tx, pending_rx) =
875            tokio::sync::oneshot::channel::<Result<PendingBlockProcessor, String>>();
876
877        let pending_indexers = c.pending_indexers;
878        let feed_handle = tokio::spawn(async move {
879            if let Err(e) = c
880                .tycho_feed
881                .run_with_pending(pending_tx, pending_indexers)
882                .await
883            {
884                tracing::error!(error = %e, "tycho feed error");
885            }
886        });
887        let gas_price_handle = tokio::spawn(async move {
888            c.gas_price_fetcher.run().await;
889        });
890        let router_fee_handle = tokio::spawn(async move {
891            c.router_fee_fetcher.run().await;
892        });
893        let computation_handle = tokio::spawn(async move {
894            c.computation_manager
895                .run(c.computation_event_rx, c.computation_shutdown_rx)
896                .await;
897        });
898
899        let pending = pending_rx
900            .await
901            .map_err(|_| SolverBuildError::PendingChannelClosed)?
902            .map_err(SolverBuildError::FeedSetup)?;
903
904        Ok((
905            Solver {
906                router: c.router,
907                worker_pools: c.worker_pools,
908                market_data: c.market_data,
909                derived_data: c.derived_data,
910                router_fees: c.router_fees,
911                feed_handle,
912                gas_price_handle,
913                router_fee_handle,
914                computation_handle,
915                computation_shutdown_tx: c.computation_shutdown_tx,
916                chain: c.chain,
917                router_address: c.router_address,
918                market_event_tx: c.market_event_tx,
919            },
920            pending,
921        ))
922    }
923
924    /// Assembles and starts all solver components, also returning a [`BlockStepController`]
925    /// that lets the caller control when each buffered block is released for processing.
926    ///
927    /// Intended for deterministic testing: call [`BlockStepController::trigger_next_block`] to
928    /// step through blocks one at a time, and [`BlockStepController::peek_next_block`] to inspect
929    /// a block before it is decoded. Dropping the controller ungates the stream so it runs to its
930    /// natural end.
931    ///
932    /// Only valid when at least one non-RFQ protocol is configured.
933    ///
934    /// # Errors
935    ///
936    /// Returns [`SolverBuildError`] if any component fails to initialize, all protocols are RFQ,
937    /// or the step-controller channel closes before the controller is delivered.
938    #[cfg(feature = "experimental")]
939    pub async fn build_with_step_controller(
940        self,
941    ) -> Result<(Solver, BlockStepController), SolverBuildError> {
942        let mut c = self.assemble_components()?;
943
944        let (controller_tx, controller_rx) =
945            tokio::sync::oneshot::channel::<Result<BlockStepController, String>>();
946
947        let feed_handle = tokio::spawn(async move {
948            if let Err(e) = c
949                .tycho_feed
950                .run_with_step_controller(controller_tx)
951                .await
952            {
953                tracing::error!(error = %e, "tycho feed error");
954            }
955        });
956        let gas_price_handle = tokio::spawn(async move {
957            c.gas_price_fetcher.run().await;
958        });
959        let router_fee_handle = tokio::spawn(async move {
960            c.router_fee_fetcher.run().await;
961        });
962        let computation_handle = tokio::spawn(async move {
963            c.computation_manager
964                .run(c.computation_event_rx, c.computation_shutdown_rx)
965                .await;
966        });
967
968        let controller = controller_rx
969            .await
970            .map_err(|_| SolverBuildError::StepControllerChannelClosed)?
971            .map_err(SolverBuildError::FeedSetup)?;
972
973        Ok((
974            Solver {
975                router: c.router,
976                worker_pools: c.worker_pools,
977                market_data: c.market_data,
978                derived_data: c.derived_data,
979                router_fees: c.router_fees,
980                feed_handle,
981                gas_price_handle,
982                router_fee_handle,
983                computation_handle,
984                computation_shutdown_tx: c.computation_shutdown_tx,
985                chain: c.chain,
986                router_address: c.router_address,
987                market_event_tx: c.market_event_tx,
988            },
989            controller,
990        ))
991    }
992} // impl FyndBuilder
993
994/// A running solver assembled by [`FyndBuilder`].
995pub struct Solver {
996    router: WorkerPoolRouter,
997    worker_pools: Vec<WorkerPool>,
998    market_data: MarketData,
999    derived_data: SharedDerivedDataRef,
1000    router_fees: SharedRouterFees,
1001    feed_handle: JoinHandle<()>,
1002    gas_price_handle: JoinHandle<()>,
1003    router_fee_handle: JoinHandle<()>,
1004    computation_handle: JoinHandle<()>,
1005    computation_shutdown_tx: broadcast::Sender<()>,
1006    chain: Chain,
1007    router_address: Bytes,
1008    market_event_tx: broadcast::Sender<MarketEvent>,
1009}
1010
1011impl Solver {
1012    /// Returns a clone of the shared market data reference.
1013    pub fn market_data(&self) -> MarketData {
1014        self.market_data.clone()
1015    }
1016
1017    /// Returns a clone of the shared derived data reference.
1018    pub fn derived_data(&self) -> SharedDerivedDataRef {
1019        Arc::clone(&self.derived_data)
1020    }
1021
1022    /// Returns a new receiver for [`MarketEvent`]s broadcast by the Tycho feed.
1023    ///
1024    /// Each call returns an independent receiver. Events are broadcast on every block update.
1025    /// Receivers created after a block has been processed will miss that block's event.
1026    pub fn subscribe_market_events(&self) -> broadcast::Receiver<crate::feed::events::MarketEvent> {
1027        self.market_event_tx.subscribe()
1028    }
1029
1030    /// Submits a [`QuoteRequest`] to the worker pools and returns the best [`Quote`].
1031    ///
1032    /// # Errors
1033    ///
1034    /// Returns [`SolveError`] if all pools fail or the router timeout elapses.
1035    pub async fn quote(&self, request: QuoteRequest) -> Result<Quote, SolveError> {
1036        self.router.quote(request).await
1037    }
1038
1039    /// Waits until the solver is ready to answer quotes.
1040    ///
1041    /// Ready means:
1042    /// - The Tycho feed has delivered at least one market snapshot.
1043    /// - The computation manager has completed at least one derived-data cycle (spot prices, pool
1044    ///   depths, token gas prices).
1045    /// - Router fees have been loaded from the on-chain FeeCalculator at least once.
1046    ///
1047    /// The method polls every 500 ms and returns as soon as all conditions are
1048    /// met, or returns [`WaitReadyError`] if `timeout` elapses first.
1049    ///
1050    /// # Example
1051    ///
1052    /// ```ignore
1053    /// solver.wait_until_ready(Duration::from_secs(180)).await?;
1054    /// ```
1055    pub async fn wait_until_ready(&self, timeout: Duration) -> Result<(), WaitReadyError> {
1056        const POLL_INTERVAL: Duration = Duration::from_millis(500);
1057
1058        let deadline = tokio::time::Instant::now() + timeout;
1059
1060        loop {
1061            let market_ready = self
1062                .market_data
1063                .read()
1064                .await
1065                .last_updated()
1066                .is_some();
1067            let derived_ready = self
1068                .derived_data
1069                .read()
1070                .await
1071                .derived_data_ready();
1072
1073            if market_ready && derived_ready {
1074                return Ok(());
1075            }
1076
1077            if tokio::time::Instant::now() >= deadline {
1078                return Err(WaitReadyError { timeout_ms: timeout.as_millis() as u64 });
1079            }
1080
1081            tokio::time::sleep(POLL_INTERVAL).await;
1082        }
1083    }
1084
1085    /// Build a Solver by replaying recorded market updates.
1086    ///
1087    /// Creates the full pipeline (feed -> derived data -> worker pools -> router)
1088    /// from pre-recorded data instead of a live Tycho connection. The returned
1089    /// Solver behaves identically to a live one — call [`wait_until_ready`](Self::wait_until_ready)
1090    /// then [`quote`](Self::quote).
1091    ///
1092    /// VM-backed protocol states that couldn't be serialized will be absent from
1093    /// the recording. Pools without states will be registered as components but
1094    /// won't contribute to routing.
1095    ///
1096    /// Requires the `test-utils` feature.
1097    #[cfg(feature = "test-utils")]
1098    pub async fn from_recording(
1099        chain: Chain,
1100        updates: Vec<tycho_simulation::protocol::models::Update>,
1101        pools: std::collections::HashMap<String, PoolConfig>,
1102        gas_price_wei: Option<num_bigint::BigUint>,
1103    ) -> Result<Self, SolverBuildError> {
1104        if pools.is_empty() {
1105            return Err(SolverBuildError::NoPools);
1106        }
1107
1108        let market_data = MarketData::new_shared();
1109
1110        // Replay updates through TychoFeed (stays pub(crate))
1111        let feed_config =
1112            TychoFeedConfig::new("ws://replay".to_string(), chain, None, false, vec![], 0.0);
1113        let feed = TychoFeed::new(feed_config, market_data.clone());
1114        let market_event_tx = feed.event_sender();
1115        let _feed_rx = feed.subscribe();
1116
1117        for update in updates {
1118            feed.handle_tycho_message(update)
1119                .await
1120                .map_err(|e| SolverBuildError::Replay(e.to_string()))?;
1121        }
1122
1123        // Inject gas price (recorded value or default 10 gwei)
1124        let gas_price = match gas_price_wei {
1125            Some(price) => price,
1126            None => {
1127                tracing::warn!("no recorded gas price, defaulting to 10 gwei");
1128                num_bigint::BigUint::from(10_000_000_000u64)
1129            }
1130        };
1131        let block_number = match market_data.read().await.last_updated() {
1132            Some(block) => block.number(),
1133            None => {
1134                tracing::warn!("no block number from replayed updates, defaulting to 0");
1135                0
1136            }
1137        };
1138        {
1139            let mut market = market_data.write().await;
1140            market.update_gas_price(BlockGasPrice {
1141                block_number,
1142                block_hash: Default::default(),
1143                block_timestamp: 0,
1144                pricing: GasPrice::Legacy { gas_price },
1145            });
1146        }
1147
1148        // Computation manager
1149        let gas_token = native_token(&chain).map_err(|_| SolverBuildError::GasToken)?;
1150        let computation_config = ComputationManagerConfig::new()
1151            .with_gas_token(gas_token)
1152            .with_depth_slippage_threshold(DEFAULT_DEPTH_SLIPPAGE_THRESHOLD);
1153        let (computation_manager, _) =
1154            ComputationManager::new(computation_config, market_data.clone())
1155                .map_err(|e| SolverBuildError::ComputationManager(e.to_string()))?;
1156
1157        let derived_data: SharedDerivedDataRef = computation_manager.store();
1158        let derived_event_tx = computation_manager.event_sender();
1159
1160        let computation_event_rx = feed.subscribe();
1161        let (computation_shutdown_tx, computation_shutdown_rx) = broadcast::channel(1);
1162
1163        let computation_handle = tokio::spawn(async move {
1164            computation_manager
1165                .run(computation_event_rx, computation_shutdown_rx)
1166                .await;
1167        });
1168
1169        // Build worker pools BEFORE sending MarketUpdated
1170        let mut solver_pool_handles: Vec<SolverPoolHandle> = Vec::new();
1171        let mut worker_pools: Vec<WorkerPool> = Vec::new();
1172        let mut max_timeout_ms = 0u64;
1173
1174        for (name, pool_cfg) in &pools {
1175            let algo_cfg = AlgorithmConfig::new(
1176                pool_cfg.min_hops(),
1177                pool_cfg.max_hops(),
1178                Duration::from_millis(pool_cfg.timeout_ms()),
1179                pool_cfg.max_routes(),
1180            )?;
1181
1182            let pool_event_rx = feed.subscribe();
1183            let derived_rx = derived_event_tx.subscribe();
1184
1185            let (worker_pool, task_handle) = WorkerPoolBuilder::new()
1186                .name(name.clone())
1187                .algorithm(pool_cfg.algorithm().to_string())
1188                .algorithm_config(algo_cfg)
1189                .num_workers(pool_cfg.num_workers())
1190                .task_queue_capacity(pool_cfg.task_queue_capacity())
1191                .build(market_data.clone(), Arc::clone(&derived_data), pool_event_rx, derived_rx)?;
1192
1193            solver_pool_handles.push(SolverPoolHandle::new(worker_pool.name(), task_handle));
1194            max_timeout_ms = max_timeout_ms.max(pool_cfg.timeout_ms());
1195            worker_pools.push(worker_pool);
1196        }
1197
1198        // Encoder + router
1199        let encoder = {
1200            let registry = SwapEncoderRegistry::new(chain)
1201                .add_default_encoders(None)
1202                .map_err(|e| SolverBuildError::Encoder(e.to_string()))?;
1203            Encoder::new(chain, registry).map_err(|e| SolverBuildError::Encoder(e.to_string()))?
1204        };
1205
1206        let router_address = encoder.router_address().clone();
1207        // Replay mode has no FeeCalculator to read; seed a zero-fee config at the standard
1208        // 8-decimal scale so the recording-based solver reports ready (integration tests do
1209        // not exercise encoding).
1210        let router_fees = encoder.router_fees();
1211        router_fees.set(crate::encoding::router_fees::RouterFees::new(
1212            100_000_000,
1213            0,
1214            0,
1215            std::collections::HashMap::new(),
1216        ));
1217        let router_config = WorkerPoolRouterConfig::default()
1218            .with_timeout(Duration::from_millis(max_timeout_ms.max(5000)))
1219            .with_min_responses(defaults::ROUTER_MIN_RESPONSES);
1220        let router = WorkerPoolRouter::new(solver_pool_handles, router_config, encoder);
1221
1222        // Trigger derived data computation
1223        let market_read = market_data.read().await;
1224        let added = market_read.component_topology();
1225        drop(market_read);
1226
1227        if market_event_tx
1228            .send(MarketEvent::MarketUpdated {
1229                added_components: added,
1230                removed_components: vec![],
1231                updated_components: vec![],
1232            })
1233            .is_err()
1234        {
1235            tracing::warn!("no receivers for initial MarketUpdated broadcast");
1236        }
1237
1238        // Dummy handles for feed/gas/router-fees (not running in replay mode). The market
1239        // event channel stays alive through the `market_event_tx` field on `Solver`.
1240        let feed_handle = tokio::spawn(futures::future::pending::<()>());
1241        let gas_price_handle = tokio::spawn(async { /* no-op */ });
1242        let router_fee_handle = tokio::spawn(async { /* no-op */ });
1243
1244        Ok(Solver {
1245            router,
1246            worker_pools,
1247            market_data,
1248            derived_data,
1249            router_fees,
1250            feed_handle,
1251            gas_price_handle,
1252            router_fee_handle,
1253            computation_handle,
1254            computation_shutdown_tx,
1255            chain,
1256            router_address,
1257            market_event_tx,
1258        })
1259    }
1260
1261    /// Signals all worker pools and the computation manager to stop, then aborts background tasks.
1262    pub fn shutdown(self) {
1263        let _ = self.computation_shutdown_tx.send(());
1264        for pool in self.worker_pools {
1265            pool.shutdown();
1266        }
1267        self.feed_handle.abort();
1268        self.gas_price_handle.abort();
1269        self.router_fee_handle.abort();
1270    }
1271
1272    /// Consumes the solver into its raw parts for callers that add their own layer.
1273    pub fn into_parts(self) -> SolverParts {
1274        SolverParts {
1275            router: self.router,
1276            worker_pools: self.worker_pools,
1277            market_data: self.market_data,
1278            derived_data: self.derived_data,
1279            router_fees: self.router_fees,
1280            feed_handle: self.feed_handle,
1281            gas_price_handle: self.gas_price_handle,
1282            router_fee_handle: self.router_fee_handle,
1283            computation_handle: self.computation_handle,
1284            computation_shutdown_tx: self.computation_shutdown_tx,
1285            chain: self.chain,
1286            router_address: self.router_address,
1287        }
1288    }
1289}
1290
1291/// Raw components of a [`Solver`], for callers adding their own layer (e.g., an HTTP server).
1292///
1293/// Obtained via [`Solver::into_parts`].
1294pub struct SolverParts {
1295    /// Routes quote requests across worker pools.
1296    router: WorkerPoolRouter,
1297    /// One [`WorkerPool`] per configured algorithm pool.
1298    worker_pools: Vec<WorkerPool>,
1299    /// Live market snapshot shared across all components.
1300    market_data: MarketData,
1301    /// Derived on-chain data (spot prices, depths, gas costs) shared across all components.
1302    derived_data: SharedDerivedDataRef,
1303    /// Router fee configuration, refreshed from chain by the router-fee fetcher.
1304    router_fees: SharedRouterFees,
1305    /// Background task running the Tycho market-data feed.
1306    feed_handle: JoinHandle<()>,
1307    /// Background task polling the RPC node for gas prices.
1308    gas_price_handle: JoinHandle<()>,
1309    /// Background task refreshing router fees from the on-chain FeeCalculator.
1310    router_fee_handle: JoinHandle<()>,
1311    /// Background task running the computation manager.
1312    computation_handle: JoinHandle<()>,
1313    /// Send a unit value on this channel to trigger a graceful computation-manager shutdown.
1314    computation_shutdown_tx: broadcast::Sender<()>,
1315    /// Chain this solver is configured for.
1316    chain: Chain,
1317    /// Address of the Tycho Router contract on this chain.
1318    router_address: Bytes,
1319}
1320
1321impl SolverParts {
1322    /// Returns the chain this solver is configured for.
1323    pub fn chain(&self) -> Chain {
1324        self.chain
1325    }
1326
1327    /// Returns the Tycho Router contract address for this chain.
1328    pub fn router_address(&self) -> &Bytes {
1329        &self.router_address
1330    }
1331
1332    /// Returns a reference to the worker pools.
1333    pub fn worker_pools(&self) -> &[WorkerPool] {
1334        &self.worker_pools
1335    }
1336
1337    /// Returns a reference to the shared market data.
1338    pub fn market_data(&self) -> &MarketData {
1339        &self.market_data
1340    }
1341
1342    /// Returns a reference to the shared derived data.
1343    pub fn derived_data(&self) -> &SharedDerivedDataRef {
1344        &self.derived_data
1345    }
1346
1347    /// Returns a reference to the shared router fee configuration.
1348    pub fn router_fees(&self) -> &SharedRouterFees {
1349        &self.router_fees
1350    }
1351
1352    /// Consumes the parts and returns the router.
1353    pub fn into_router(self) -> WorkerPoolRouter {
1354        self.router
1355    }
1356
1357    /// Consumes the parts, returning all owned components.
1358    #[allow(clippy::type_complexity)]
1359    pub fn into_components(
1360        self,
1361    ) -> (
1362        WorkerPoolRouter,
1363        Vec<WorkerPool>,
1364        MarketData,
1365        SharedDerivedDataRef,
1366        JoinHandle<()>,
1367        JoinHandle<()>,
1368        JoinHandle<()>,
1369        JoinHandle<()>,
1370        broadcast::Sender<()>,
1371    ) {
1372        (
1373            self.router,
1374            self.worker_pools,
1375            self.market_data,
1376            self.derived_data,
1377            self.feed_handle,
1378            self.gas_price_handle,
1379            self.router_fee_handle,
1380            self.computation_handle,
1381            self.computation_shutdown_tx,
1382        )
1383    }
1384}